diff --git a/.changes/i18n-catalog-snackbars-external-player.md b/.changes/i18n-catalog-snackbars-external-player.md new file mode 100644 index 000000000..3990cf7ce --- /dev/null +++ b/.changes/i18n-catalog-snackbars-external-player.md @@ -0,0 +1,9 @@ +--- +type: fix +area: i18n +--- + +Catalog screens, notifications and external player messages now appear in the +app's language. This covers "All items" and item counts, channel sort menus, +request and backup notifications, and MPV/VLC launch and playback errors. +Several playlist messages that showed a raw key now show text. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 991cbc276..bd5d050e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -425,10 +425,11 @@ jobs: - name: Typecheck Jest spec programs run: pnpm run typecheck:spec:test && pnpm run typecheck:spec - # Fails on missing or extra keys and on values identical to - # English that tools/i18n/identical-en-baseline.json does not list. - # The baseline only changes through a reviewed - # `pnpm run i18n:baseline:update`; CI never rewrites it. + # Fails on missing or extra keys, on values identical to English + # that tools/i18n/identical-en-baseline.json does not list, and on + # keys the renderer uses but en.json lacks. The baseline only + # changes through a reviewed `pnpm run i18n:baseline:update`; CI + # never rewrites it. - name: Check i18n drift and untranslated values run: pnpm run i18n:validate diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index 98485b1da..ba648ec2d 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -361,7 +361,7 @@ const largeXtreamCredentials = { async function setLiveSortMode( page: Page, - label: 'Server Order' | 'Name A-Z' | 'Name Z-A' + label: 'Server order' | 'Name A-Z' | 'Name Z-A' ): Promise { await page.getByRole('button', { name: 'Sort channels' }).click(); await page.getByRole('menuitem', { name: label, exact: true }).click(); diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index ce890eb86..d87fbba4f 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -888,7 +888,12 @@ test('@electron @dash ClearKey DASH filters DRM fallback and reports external la await expect(mpvFallback).toHaveClass( /web-player-diagnostic__player-card--primary/ ); - await expect(dock).toContainText('E2E player launch failed'); + // A failure without an error code shows the translated generic + // status; the raw main-process detail stays available as a tooltip. + await expect(dock).toContainText('External player error'); + await expect( + dock.locator('.external-playback-dock__status-text') + ).toHaveAttribute('title', 'E2E player launch failed'); await expect( dock.getByRole('button', { name: 'Dismiss' }) ).toBeVisible(); diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index e3a97d7a3..381068e75 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -486,7 +486,7 @@ test.describe('Electron Workspace Search', () => { await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); await expectWorkspaceSearchScope( app.mainWindow, - 'Live TV / All Items' + 'Live TV / All items' ); await expect( liveChannelSidebar(app.mainWindow) @@ -1524,7 +1524,7 @@ async function expectXtreamRootCatalogSearch( await expectQueryParam(page, 'q', options.sample.targetTitle); await expectWorkspaceSearchScope( page, - `${options.sectionLabel} / All Items` + `${options.sectionLabel} / All items` ); const contentLayout = page.locator('.category-content-layout'); await expect(contentLayout).toContainText( diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 8c4557ebd..8fa824fde 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -48,6 +48,7 @@ import type { ElectronBridgeEpgLookupOptions, ElectronBridgeEpgProgress, ElectronBridgePlaybackPositionInput, + ElectronBridgePlayerError, ElectronBridgePlaylistInput, ElectronBridgePlaylistOpenRequest, ElectronBridgePlaylistUpsertInput, @@ -312,13 +313,7 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.send('REMOTE_CONTROL_STATUS_UPDATE', status); }, // Player error listener - onPlayerError: ( - callback: (data: { - player: string; - error: string; - originalError: string; - }) => void - ) => { + onPlayerError: (callback: (data: ElectronBridgePlayerError) => void) => { ipcRenderer.on('player-error', (_event, data) => callback(data)); }, onPortalDebugEvent: (callback: (data: PortalDebugEvent) => void) => { diff --git a/apps/electron-backend/src/app/events/external-player-process.ts b/apps/electron-backend/src/app/events/external-player-process.ts index e465994f5..ba7475877 100644 --- a/apps/electron-backend/src/app/events/external-player-process.ts +++ b/apps/electron-backend/src/app/events/external-player-process.ts @@ -1,4 +1,5 @@ import type { ChildProcess } from 'child_process'; +import { tagExternalPlayerError } from '@iptvnator/shared/interfaces'; const TERMINATION_GRACE_PERIOD_MS = 3_000; const FORCED_TERMINATION_WAIT_MS = 2_000; @@ -7,8 +8,10 @@ interface TerminateExternalPlayerProcessOptions { sendTerminationSignal?: boolean; } -const EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR = - 'Cannot launch player because the previous external player is still shutting down'; +const EXTERNAL_PLAYER_TEARDOWN_PENDING_ERROR = tagExternalPlayerError( + 'previous-closing', + 'Cannot launch player because the previous external player is still shutting down' +); /** * Serializes replacement launches against exact children whose exit has not diff --git a/apps/electron-backend/src/app/events/external-player-runtime.spec.ts b/apps/electron-backend/src/app/events/external-player-runtime.spec.ts new file mode 100644 index 000000000..85606d0ba --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-runtime.spec.ts @@ -0,0 +1,71 @@ +const send = jest.fn(); + +jest.mock('../app', () => ({ + __esModule: true, + default: { + mainWindow: { + isDestroyed: () => false, + webContents: { send: (...args: unknown[]) => send(...args) }, + }, + }, +})); + +import { readExternalPlayerErrorCode } from '@iptvnator/shared/interfaces'; +import { + buildPlayerStartError, + classifyPlayerError, + sendPlayerErrorNotification, +} from './external-player-runtime'; + +describe('external player error notifications', () => { + beforeEach(() => send.mockClear()); + + it.each([ + ['Failed to open https://example.test/live.m3u8', 'stream-open-failed'], + ['Protocol not found', 'unsupported-protocol'], + ['Connection refused', 'connection-failed'], + ['HTTP error 403 Forbidden', 'access-denied'], + ['HTTP error 404 Not Found', 'stream-not-found'], + ['tcp: Timed out', 'timed-out'], + ['some unrecognised output', null], + ])('classifies %j as %s', (output, code) => { + expect(classifyPlayerError(output)).toBe(code); + }); + + it('sends a code and the raw output instead of an English sentence', () => { + sendPlayerErrorNotification('MPV', 'HTTP error 403 Forbidden'); + + expect(send).toHaveBeenCalledWith('player-error', { + player: 'MPV', + code: 'access-denied', + error: 'HTTP error 403 Forbidden', + }); + }); + + it('lets the caller name the code', () => { + sendPlayerErrorNotification( + 'VLC', + 'VLC player closed unexpectedly (exit code: 1)', + 'closed-unexpectedly' + ); + + expect(send).toHaveBeenCalledWith( + 'player-error', + expect.objectContaining({ code: 'closed-unexpectedly' }) + ); + }); + + it('tags start failures so the renderer can translate the rejection', () => { + const error = buildPlayerStartError('MPV', new Error('spawn ENOENT'), { + mode: 'direct', + playerPath: 'mpv', + command: 'mpv', + argsPrefix: [], + }); + + expect(readExternalPlayerErrorCode(error)).toBe('start-failed'); + expect(error.message).toContain( + 'Failed to start MPV player: spawn ENOENT' + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/external-player-runtime.ts b/apps/electron-backend/src/app/events/external-player-runtime.ts index 29cfee380..fbbbb1878 100644 --- a/apps/electron-backend/src/app/events/external-player-runtime.ts +++ b/apps/electron-backend/src/app/events/external-player-runtime.ts @@ -1,7 +1,10 @@ import { + ElectronBridgePlayerError, EXTERNAL_PLAYER_SESSION_UPDATE, + ExternalPlayerErrorCode, ExternalPlayerSession, PlayerContentInfo, + tagExternalPlayerError, } from '@iptvnator/shared/interfaces'; import App from '../app'; import { isExternalPlayerTraceEnabled, trace } from '../services/debug-trace'; @@ -45,49 +48,58 @@ export function buildPlayerStartError( : `Make sure ${player} is installed and the path '${launchContext.playerPath}' is correct.`; return new Error( - `Failed to start ${player} player: ${error.message}. ${guidance}` + tagExternalPlayerError( + 'start-failed', + `Failed to start ${player} player: ${error.message}. ${guidance}` + ) ); } +/** + * Maps player output to a code the renderer translates. Returns null for + * output no code describes; the renderer then shows the raw output. + */ +export function classifyPlayerError( + error: string +): ExternalPlayerErrorCode | null { + if (error.includes('Failed to open')) { + return 'stream-open-failed'; + } + if ( + error.includes('Protocol not found') || + error.includes('Unsupported protocol') + ) { + return 'unsupported-protocol'; + } + if ( + error.includes('Connection refused') || + error.includes('Could not connect') + ) { + return 'connection-failed'; + } + if (error.includes('403') || error.includes('Forbidden')) { + return 'access-denied'; + } + if (error.includes('404') || error.includes('Not Found')) { + return 'stream-not-found'; + } + if (error.includes('Timed out') || error.includes('timeout')) { + return 'timed-out'; + } + return null; +} + export function sendPlayerErrorNotification( player: 'MPV' | 'VLC', - error: string + error: string, + code: ExternalPlayerErrorCode | null = classifyPlayerError(error) ): void { if (!App.mainWindow || App.mainWindow.isDestroyed()) { return; } - let userMessage = error; - - if (error.includes('Failed to open')) { - userMessage = - 'Failed to open stream. The URL may be invalid or the server is not responding.'; - } else if ( - error.includes('Protocol not found') || - error.includes('Unsupported protocol') - ) { - userMessage = - 'Unsupported stream protocol. Please check the stream URL.'; - } else if ( - error.includes('Connection refused') || - error.includes('Could not connect') - ) { - userMessage = - 'Cannot connect to the stream server. Please check your internet connection.'; - } else if (error.includes('403') || error.includes('Forbidden')) { - userMessage = - 'Access denied. The stream may require valid credentials or headers.'; - } else if (error.includes('404') || error.includes('Not Found')) { - userMessage = 'Stream not found. The URL may be incorrect or expired.'; - } else if (error.includes('Timed out') || error.includes('timeout')) { - userMessage = 'Connection timed out. The server is not responding.'; - } - - App.mainWindow.webContents.send('player-error', { - player, - error: userMessage, - originalError: error, - }); + const payload: ElectronBridgePlayerError = { player, code, error }; + App.mainWindow.webContents.send('player-error', payload); } export function sendPlaybackPositionUpdate( diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts index 97a9e2ffa..21ea3865d 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts @@ -1,4 +1,7 @@ -import { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import { + ExternalPlayerSession, + tagExternalPlayerError, +} from '@iptvnator/shared/interfaces'; import { ExternalPlayerSessionRegistry } from './external-player-session-registry'; describe('ExternalPlayerSessionRegistry', () => { @@ -101,6 +104,28 @@ describe('ExternalPlayerSessionRegistry', () => { expect(registry.getActiveSessionId()).toBe(session.id); }); + it('stores a tagged launch failure as its code and plain detail', () => { + const session = registry.beginSession({ + player: 'vlc', + title: 'Example', + streamUrl: 'https://example.com/video.m3u8', + }); + + const errored = registry.markError( + session.id, + tagExternalPlayerError( + 'start-failed', + 'Failed to start VLC player: spawn vlc ENOENT' + ), + { code: 'start-failed' } + ); + + expect(errored?.errorCode).toBe('start-failed'); + expect(errored?.error).toBe( + 'Failed to start VLC player: spawn vlc ENOENT' + ); + }); + it('does not overwrite terminal status with late lifecycle updates', () => { const failed = registry.beginSession({ player: 'mpv', diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.ts b/apps/electron-backend/src/app/events/external-player-session-registry.ts index 97ce71ce5..a20511330 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.ts @@ -1,7 +1,9 @@ import { + ExternalPlayerErrorCode, ExternalPlayerName, ExternalPlayerSession, PlayerContentInfo, + stripExternalPlayerErrorTag, } from '@iptvnator/shared/interfaces'; interface CreateExternalPlayerSessionOptions { @@ -15,6 +17,7 @@ interface CreateExternalPlayerSessionOptions { interface UpdateExternalPlayerSessionOptions { status?: ExternalPlayerSession['status']; error?: string; + errorCode?: ExternalPlayerErrorCode; canClose?: boolean; } @@ -25,6 +28,8 @@ interface ExternalPlayerSessionRuntime { interface MarkExternalPlayerSessionErrorOptions { canClose?: boolean; + /** What the renderer shows; without one it shows a generic failure. */ + code?: ExternalPlayerErrorCode; } function isRestorableSession(session: ExternalPlayerSession): boolean { @@ -176,9 +181,12 @@ export class ExternalPlayerSessionRegistry { return current; } + // The code tag only carries the code across IPC; the renderer shows + // the stored error as detail. return this.updateSession(id, { status: 'error', - error, + error: stripExternalPlayerErrorTag(error), + errorCode: options.code, canClose: options.canClose ?? false, }); } diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index f4d67df9a..f70ff2a2f 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -1,6 +1,9 @@ import { spawn } from 'child_process'; import { createConnection } from 'net'; -import { PlayerContentInfo } from '@iptvnator/shared/interfaces'; +import { + PlayerContentInfo, + readExternalPlayerErrorCode, +} from '@iptvnator/shared/interfaces'; import { MPV_PLAYER_ARGUMENTS, MPV_PLAYER_PATH, @@ -419,7 +422,8 @@ export async function openMpvPlayer({ stopPositionPolling(); externalPlayerSessions.markError( processSessionId, - `Failed to start MPV player: ${err.message}` + `Failed to start MPV player: ${err.message}`, + { code: 'start-failed' } ); rejectLaunch( buildPlayerStartError('MPV', err, mpvLaunchContext) @@ -441,11 +445,13 @@ export async function openMpvPlayer({ ); sendPlayerErrorNotification( 'MPV', - `MPV player closed unexpectedly (exit code: ${code})` + `MPV player closed unexpectedly (exit code: ${code})`, + 'closed-unexpectedly' ); externalPlayerSessions.markError( processSessionId, - `MPV player closed unexpectedly (exit code: ${code})` + `MPV player closed unexpectedly (exit code: ${code})`, + { code: 'closed-unexpectedly' } ); resolveLaunch(); return; @@ -520,6 +526,7 @@ export async function openMpvPlayer({ session.id, error instanceof Error ? error.message : String(error), { + code: readExternalPlayerErrorCode(error) ?? undefined, canClose: freshTeardownUnconfirmed || (reuseState.teardownUnconfirmed && diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index 4c24dbb65..615f318f5 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -1,6 +1,9 @@ import { spawn } from 'child_process'; import { AddressInfo, createServer } from 'net'; -import { PlayerContentInfo } from '@iptvnator/shared/interfaces'; +import { + PlayerContentInfo, + readExternalPlayerErrorCode, +} from '@iptvnator/shared/interfaces'; import { VLC_PLAYER_ARGUMENTS, VLC_PLAYER_PATH, @@ -357,7 +360,13 @@ export async function openVlcPlayer({ if (current?.status !== 'closed') { externalPlayerSessions.markError( session.id, - launchError.message + launchError.message, + { + code: + readExternalPlayerErrorCode( + launchError + ) ?? undefined, + } ); } } else { @@ -539,7 +548,8 @@ export async function openVlcPlayer({ } else { externalPlayerSessions.markError( processSessionId, - `Failed to start VLC player: ${err.message}` + `Failed to start VLC player: ${err.message}`, + { code: 'start-failed' } ); rejectSpawn( buildPlayerStartError('VLC', err, vlcLaunchContext) @@ -596,11 +606,13 @@ export async function openVlcPlayer({ ); sendPlayerErrorNotification( 'VLC', - `VLC player closed unexpectedly (exit code: ${code})` + `VLC player closed unexpectedly (exit code: ${code})`, + 'closed-unexpectedly' ); externalPlayerSessions.markError( processSessionId, - `VLC player closed unexpectedly (exit code: ${code})` + `VLC player closed unexpectedly (exit code: ${code})`, + { code: 'closed-unexpectedly' } ); resolveSpawn(); return; @@ -634,6 +646,7 @@ export async function openVlcPlayer({ session.id, error instanceof Error ? error.message : String(error), { + code: readExternalPlayerErrorCode(error) ?? undefined, canClose: freshTeardownUnconfirmed || (reuseState.teardownUnconfirmed && diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 1414ef7d9..2025c58c4 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -19,6 +19,7 @@ import { XTREAM_REQUEST, Playlist, SECURITY_ERROR_PREFIX, + tagExternalPlayerError, } from '@iptvnator/shared/interfaces'; import { ElectronService } from './electron.service'; @@ -209,11 +210,19 @@ describe('ElectronService', () => { status, }); expect(snackBar.open).toHaveBeenCalledTimes(1); - expect(snackBar.open.mock.calls[0][0]).toContain( - `status: ${status}` + expect(snackBar.open).toHaveBeenCalledWith( + 'PORTALS.REQUEST_ERRORS.STALKER', + 'CLOSE', + { duration: 5000 } ); - expect(snackBar.open.mock.calls[0][0]).not.toContain( - 'Error invoking remote method' + // The toast carries the portal's own error, not Electron's + // "Error invoking remote method" wrapper. + expect(translateService.instant).toHaveBeenCalledWith( + 'PORTALS.REQUEST_ERRORS.STALKER', + { + message: `HTTP Error ${status}: ${status === 401 ? 'Unauthorized' : 'Forbidden'}`, + status, + } ); } ); @@ -310,8 +319,9 @@ describe('ElectronService', () => { message: `HTTP Error ${status}: ${status === 401 ? 'Unauthorized' : 'Forbidden'}`, }); expect(snackBar.open).toHaveBeenCalledTimes(1); - expect(snackBar.open.mock.calls[0][0]).toContain( - `HTTP Error ${status}` + expect(translateService.instant).toHaveBeenCalledWith( + 'PORTALS.REQUEST_ERRORS.XTREAM', + { message: expect.stringContaining(`HTTP Error ${status}`) } ); } ); @@ -397,8 +407,8 @@ describe('ElectronService', () => { listener({ player: 'MPV', - error: 'Playback failed', - originalError: `Request failed: token=${secret}&channel=news`, + code: null, + error: `Request failed: token=${secret}&channel=news`, }); const output = JSON.stringify((console.error as jest.Mock).mock.calls); @@ -406,6 +416,71 @@ describe('ElectronService', () => { expect(output).toContain('channel=news'); }); + it('translates a classified player error by its code', () => { + const listener = electronBridge.onPlayerError.mock.calls[0][0]; + + listener({ player: 'VLC', code: 'access-denied', error: 'HTTP 403' }); + + expect(translateService.instant).toHaveBeenCalledWith( + 'EXTERNAL_PLAYER.ERRORS.ACCESS_DENIED', + { player: 'VLC', error: 'HTTP 403' } + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'EXTERNAL_PLAYER.ERRORS.ACCESS_DENIED', + 'CLOSE', + expect.objectContaining({ duration: 7000 }) + ); + }); + + it('keeps unclassified player output in the translated message', () => { + const listener = electronBridge.onPlayerError.mock.calls[0][0]; + + listener({ player: 'MPV', code: null, error: 'demuxer gave up' }); + + expect(translateService.instant).toHaveBeenCalledWith( + 'EXTERNAL_PLAYER.ERRORS.UNKNOWN', + { player: 'MPV', error: 'demuxer gave up' } + ); + }); + + it('translates a rejected launch by the code the main process tagged', async () => { + electronBridge.openInVlc.mockRejectedValueOnce( + new Error( + `Error invoking remote method 'OPEN_VLC_PLAYER': Error: ${tagExternalPlayerError( + 'previous-closing', + 'Cannot launch player' + )}` + ) + ); + + await expect( + service.sendIpcEvent('OPEN_VLC_PLAYER', { + url: 'https://example.test/live.m3u8', + }) + ).rejects.toThrow('Cannot launch player'); + + expect(snackBar.open).toHaveBeenCalledWith( + 'EXTERNAL_PLAYER.ERRORS.PREVIOUS_CLOSING', + 'CLOSE', + { duration: 5000 } + ); + }); + + it('shows a generic launch failure for an untagged rejection', async () => { + electronBridge.openInMpv.mockRejectedValueOnce(new Error('boom')); + + await expect( + service.sendIpcEvent('OPEN_MPV_PLAYER', { + url: 'https://example.test/live.m3u8', + }) + ).rejects.toThrow('boom'); + + expect(translateService.instant).toHaveBeenCalledWith( + 'EXTERNAL_PLAYER.ERRORS.LAUNCH_FAILED', + { player: 'MPV' } + ); + }); + it('shows the trust-host action for Electron-wrapped security errors', async () => { const securityPayload = { code: ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate, diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 753bbd404..e7fe05417 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -15,7 +15,11 @@ import { CONNECTIVITY_GUARD_RESET, createPortalRequestError, ELECTRON_BRIDGE_SECURITY_ERROR_CODES, + ElectronBridgePlayerError, ERROR, + EXTERNAL_PLAYER_ERROR_KEYS, + EXTERNAL_PLAYER_LAUNCH_FAILED_KEY, + EXTERNAL_PLAYER_UNKNOWN_ERROR_KEY, isPortalRequestCancelledError, isPortalRequestFailureEnvelope, normalizeHost, @@ -24,6 +28,7 @@ import { Playlist, PLAYLIST_PARSE_BY_URL, PLAYLIST_UPDATE, + readExternalPlayerErrorCode, XTREAM_REQUEST, XTREAM_RESPONSE, XtreamCodeActions, @@ -90,29 +95,39 @@ export class ElectronService extends DataService { private setupPlayerErrorListener() { // Listen for player errors from the backend if (window.electron?.onPlayerError) { - window.electron.onPlayerError( - (data: { - player: string; - error: string; - originalError: string; - }) => { - this.logger.error( - `${data.player} Error:`, - data.originalError - ); - this.snackBar.open( - `${data.player} Error: ${data.error}`, - 'Close', - { - duration: 7000, - panelClass: ['error-snackbar'], - } - ); - } - ); + window.electron.onPlayerError((data: ElectronBridgePlayerError) => { + this.logger.error(`${data.player} Error:`, data.error); + this.snackBar.open( + this.translateService.instant( + data.code + ? EXTERNAL_PLAYER_ERROR_KEYS[data.code] + : EXTERNAL_PLAYER_UNKNOWN_ERROR_KEY, + { player: data.player, error: data.error } + ), + this.translateService.instant('CLOSE'), + { + duration: 7000, + panelClass: ['error-snackbar'], + } + ); + }); } } + private showPlayerLaunchError(player: 'MPV' | 'VLC', error: unknown): void { + const code = readExternalPlayerErrorCode(error); + this.snackBar.open( + this.translateService.instant( + code + ? EXTERNAL_PLAYER_ERROR_KEYS[code] + : EXTERNAL_PLAYER_LAUNCH_FAILED_KEY, + { player } + ), + this.translateService.instant('CLOSE'), + { duration: 5000 } + ); + } + private setupPortalDebugListener() { const onPortalDebugEvent = ( window.electron as { @@ -200,15 +215,7 @@ export class ElectronService extends DataService { data.headers ?? undefined )) as T; } catch (error: unknown) { - const errorMessage = - this.getErrorDetails(error)?.message ?? String(error); - this.snackBar.open( - `Error launching MPV: ${errorMessage}`, - 'Close', - { - duration: 5000, - } - ); + this.showPlayerLaunchError('MPV', error); this.logger.error('MPV launch error:', error); throw error; } @@ -229,15 +236,7 @@ export class ElectronService extends DataService { data.headers ?? undefined )) as T; } catch (error: unknown) { - const errorMessage = - this.getErrorDetails(error)?.message ?? String(error); - this.snackBar.open( - `Error launching VLC: ${errorMessage}`, - 'Close', - { - duration: 5000, - } - ); + this.showPlayerLaunchError('VLC', error); this.logger.error('VLC launch error:', error); throw error; } @@ -341,8 +340,18 @@ export class ElectronService extends DataService { this.logger.error('Stalker request error:', err); if (!payload.silent) { this.snackBar.open( - `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, - 'Close', + this.translateService.instant( + 'PORTALS.REQUEST_ERRORS.STALKER', + { + message: + errorInfo?.message ?? + this.translateService.instant( + 'PORTALS.REQUEST_ERRORS.NOT_FOUND' + ), + status: errorInfo?.status ?? 404, + } + ), + this.translateService.instant('CLOSE'), { duration: 5000, } @@ -701,8 +710,11 @@ export class ElectronService extends DataService { // Only show snackbar for user-triggered Xtream requests if (!isSilentAction) { this.snackBar.open( - `Xtream request failed: ${normalizedMessage}`, - 'Close', + this.translateService.instant( + 'PORTALS.REQUEST_ERRORS.XTREAM', + { message: normalizedMessage } + ), + this.translateService.instant('CLOSE'), { duration: 5000, } @@ -720,7 +732,9 @@ export class ElectronService extends DataService { } private getReadableXtreamErrorMessage(error: unknown): string { - const fallback = 'Failed to connect to Xtream server'; + const fallback = this.translateService.instant( + 'PORTALS.REQUEST_ERRORS.XTREAM_CONNECTION' + ); if (!error) { return fallback; } diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index 87a861bf4..e3bc14800 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -154,6 +154,12 @@ describe('PwaService', () => { } ); + it('translates the too-large upload message', () => { + expect(service.getErrorMessageByStatusCode(413)).toBe( + 'HOME.URL_UPLOAD.ERROR_413' + ); + }); + it('appends the proxy network code to the URL-import failure toast', async () => { // The /parse proxy reports connection-level failures as HTTP 500 with // a `code` field in the body (#1400). The toast must carry that code — @@ -187,7 +193,7 @@ describe('PwaService', () => { expect(TestBed.inject(MatSnackBar).open).toHaveBeenCalledWith( 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED (ETIMEDOUT)', - 'Close', + 'CLOSE', { duration: 5000 } ); } finally { diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 2dc932a75..1d7152983 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -297,7 +297,7 @@ export class PwaService extends DataService { ), error ), - 'Close', + this.translateService.instant('CLOSE'), { duration: 5000, } @@ -327,7 +327,8 @@ export class PwaService extends DataService { let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED'; switch (status) { case 413: - return 'This file is too big. Use standalone or self-hosted version of the app.'; + messageKey = 'HOME.URL_UPLOAD.ERROR_413'; + break; case 403: messageKey = 'HOME.URL_UPLOAD.ERROR_403'; break; @@ -503,8 +504,10 @@ export class PwaService extends DataService { this.logger.error('Xtream request error:', normalizedMessage); this.snackBar.open( - `Xtream request failed: ${normalizedMessage}`, - 'Close', + this.translateService.instant('PORTALS.REQUEST_ERRORS.XTREAM', { + message: normalizedMessage, + }), + this.translateService.instant('CLOSE'), { duration: 5000, } @@ -518,7 +521,9 @@ export class PwaService extends DataService { } private getReadableXtreamErrorMessage(error: unknown): string { - const fallback = 'Failed to connect to Xtream server'; + const fallback = this.translateService.instant( + 'PORTALS.REQUEST_ERRORS.XTREAM_CONNECTION' + ); if (!error) { return fallback; } @@ -693,8 +698,18 @@ export class PwaService extends DataService { if (!payload.silent) { this.snackBar.open( - `Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`, - 'Close', + this.translateService.instant( + 'PORTALS.REQUEST_ERRORS.STALKER', + { + message: + errorInfo?.message ?? + this.translateService.instant( + 'PORTALS.REQUEST_ERRORS.NOT_FOUND' + ), + status: errorInfo?.status ?? 404, + } + ), + this.translateService.instant('CLOSE'), { duration: 5000, } diff --git a/apps/web/src/app/settings/settings-backup.facade.spec.ts b/apps/web/src/app/settings/settings-backup.facade.spec.ts index 0fcce600a..9a9600ee7 100644 --- a/apps/web/src/app/settings/settings-backup.facade.spec.ts +++ b/apps/web/src/app/settings/settings-backup.facade.spec.ts @@ -153,7 +153,7 @@ describe('SettingsBackupFacade', () => { expect(facade.isExportingData()).toBe(false); expect(snackBar.open).toHaveBeenCalledWith( - 'Playlist backup export failed.', + 'SETTINGS.BACKUP_EXPORT_FAILED', undefined, expect.objectContaining({ panelClass: ['settings-snackbar'] }) ); @@ -190,6 +190,13 @@ describe('SettingsBackupFacade', () => { )?.[1] as (event: Event) => Promise; await changeListener({ target: input } as unknown as Event); + expect( + (TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub).open + ).toHaveBeenCalledWith( + 'SETTINGS.BACKUP_IMPORT_SUMMARY', + undefined, + expect.objectContaining({ panelClass: ['settings-snackbar'] }) + ); expect(xtreamStore.reconcilePendingRestoreBlock).toHaveBeenCalledTimes( 1 ); diff --git a/apps/web/src/app/settings/settings-backup.facade.ts b/apps/web/src/app/settings/settings-backup.facade.ts index 91ed0d714..50dc7d934 100644 --- a/apps/web/src/app/settings/settings-backup.facade.ts +++ b/apps/web/src/app/settings/settings-backup.facade.ts @@ -55,10 +55,14 @@ export class SettingsBackupFacade { ); } - this.settingsSnackbar.open('Playlist backup exported.'); + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.BACKUP_EXPORTED') + ); } catch (error) { console.error('Failed to export playlist backup:', error); - this.settingsSnackbar.open('Playlist backup export failed.'); + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.BACKUP_EXPORT_FAILED') + ); } finally { this.isExportingData.set(false); } @@ -132,6 +136,11 @@ export class SettingsBackupFacade { private buildBackupImportSummary( summary: PlaylistBackupImportSummary ): string { - return `Backup import finished: ${summary.imported} imported, ${summary.merged} merged, ${summary.skipped} skipped, ${summary.failed} failed.`; + return this.translate.instant('SETTINGS.BACKUP_IMPORT_SUMMARY', { + imported: summary.imported, + merged: summary.merged, + skipped: summary.skipped, + failed: summary.failed, + }); } } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 57fae439f..11963ab29 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "قائمة تشغيل Xtream", "STALKER_PORTAL": "بوابة Stalker", "CHANNELS": "القنوات", + "CHANNELS_COUNT": "{{count}} قناة", "ADDED": "تمت الإضافة", "SHOW_DETAILS": "عرض تفاصيل القائمة", "REMOVE": "حذف القائمة", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "عنوان MAC", "PLAYLIST_EXPORT_SUCCESS": "تم تصدير القائمة بنجاح.", "PLAYLIST_EXPORT_ERROR": "خطأ: فشل تصدير القائمة.", + "EXPORT_PLAYLIST_FAILED": "فشل تصدير القائمة.", "COPY_URL": "نسخ الرابط إلى الحافظة", "URL_COPIED": "تم نسخ الرابط إلى الحافظة" }, @@ -107,6 +109,10 @@ }, "UPDATED": "تم التحديث", "REFRESH": "تحديث القائمة", + "REFRESH_FETCHING": "جارٍ جلب قائمة التشغيل…", + "REFRESH_READING_FILE": "جارٍ قراءة ملف قائمة التشغيل…", + "REFRESH_PARSING": "جارٍ تحليل قائمة التشغيل…", + "REFRESH_SAVING": "جارٍ حفظ قائمة التشغيل…", "REFRESH_XTREAM": "تحديث قائمة Xtream من الخادم البعيد", "REFRESH_XTREAM_DIALOG": { "TITLE": "تحديث قائمة Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "جميع القوائم المتاحة", "PLAYLIST_UPDATE_SUCCESS": "نجاح! تم تحديث القائمة.", "PLAYLIST_UPDATE_ERROR": "خطأ في تحديث تفاصيل القائمة.", + "PLAYLIST_UPDATE_FAILED": "تعذّر تحديث قائمة التشغيل.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "فشل تحديث قائمة التشغيل. الملف المحلي لم يعد متاحًا. تحقق من مسار الملف أو أعد استيراد قائمة التشغيل.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "فشل تحديث قائمة التشغيل. لم يعد بإمكان التطبيق الوصول إلى الملف المحلي.", "AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! تم تحديث قوائم التشغيل بنجاح", @@ -166,6 +173,7 @@ "ERROR_403": "تم رفض الوصول (403): رفض الخادم الطلب. قد يتطلب الرابط مصادقة أو أن قائمة التشغيل مقيدة.", "ERROR_404": "قائمة التشغيل غير موجودة (404): الرابط لا يشير إلى قائمة تشغيل صالحة. يرجى التحقق من الرابط.", "ERROR_401": "غير مصرح (401): المصادقة مطلوبة للوصول إلى قائمة التشغيل هذه.", + "ERROR_413": "هذا الملف كبير جدًا. استخدم الإصدار المستقل أو الإصدار المستضاف ذاتيًا من التطبيق.", "ERROR_INVALID_TLS": "شهادة مضيف قائمة التشغيل هذه غير صالحة.", "ERROR_TLS_HOST_UNKNOWN": "تعذر تحديد مضيف قائمة التشغيل. يرجى إعادة المحاولة يدويًا.", "TRUST_TLS_HOST": "الثقة بالمضيف", @@ -466,6 +474,8 @@ "IMPORT_DATA": "استيراد", "EXPORT_DATA": "تصدير", "EXPORTING_DATA": "جارٍ التصدير…", + "BACKUP_EXPORTED": "تم تصدير النسخة الاحتياطية لقوائم التشغيل.", + "BACKUP_EXPORT_FAILED": "فشل تصدير النسخة الاحتياطية لقوائم التشغيل.", "REMOVE_ALL": "حذف كل قوائم التشغيل وبياناتها", "REMOVE_ALL_BUTTON": "حذف الكل", "REMOVE_ALL_DESCRIPTION": "احذف كل قوائم تشغيل M3U و Xtream و Stalker المخزنة في التطبيق.", @@ -483,6 +493,7 @@ "CONFIRM": "حذف كل شيء" }, "IMPORT_ERROR": "خطأ في الاستيراد، يرجى تجربة ملف آخر.", + "BACKUP_IMPORT_SUMMARY": "اكتمل استيراد النسخة الاحتياطية: تم استيراد {{imported}}، ودمج {{merged}}، وتخطي {{skipped}}، وفشل {{failed}}.", "PLAYLISTS_REMOVED": "تمت إزالة كل قوائم التشغيل وبياناتها.", "MPV_PLAYER_PATH_LABEL": "مسار mpv", "MPV_PLAYER_PATH_DESCRIPTION": "حدد مسار MPV. على macOS يمكنك استخدام حزمة تطبيق MPV مثل /Applications/mpv.app أو مسار الملف التنفيذي.", @@ -623,7 +634,9 @@ "NEXT_STATION": "المحطة التالية", "MUTE": "كتم الصوت", "UNMUTE": "إلغاء كتم الصوت", - "VOLUME": "مستوى الصوت" + "VOLUME": "مستوى الصوت", + "RADIO": "راديو", + "PAUSED_BADGE": "متوقف مؤقتًا" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "تاريخ الإضافة (الأحدث أولاً)", "SORT_BY_RATING": "التقييم", "SORT_BY_NAME": "الاسم (أ-ي)", + "SORT_CHANNELS": "ترتيب القنوات", + "SORT_SERVER_ORDER": "ترتيب الخادم", + "SORT_PLAYLIST_ORDER": "ترتيب قائمة التشغيل", "LOAD_MORE": "تحميل المزيد", "REMAINING": "متبقية", "LOADING": "جارٍ تحميل القنوات...", @@ -1132,6 +1148,7 @@ "DURATION": "المدة", "CAST": "طاقم التمثيل", "EPISODE_RUN_TIME": "مدة الحلقة", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} د/حلقة", "RATING": "التقييم", "IMDB_RATING": "تقييم IMDb", "KINOPOISK_RATING": "تقييم Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "بحث في الفئات...", "NO_RESULTS": "لم يتم العثور على فئات مطابقة", "NO_CATEGORIES": "لا توجد فئات متاحة", - "SAVE": "حفظ" - } + "SAVE": "حفظ", + "SAVE_FAILED": "فشل حفظ حالة عرض الفئات" + }, + "GLOBAL_SEARCH_SUMMARY": "تم العثور على {{count}} عنصر في {{playlists}} من قوائم التشغيل" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "إعادة المحاولة الآن" }, "ALL_CATEGORIES": "جميع الفئات", + "ALL_ITEMS": "جميع العناصر", + "UNNAMED_CATEGORY": "فئة بدون اسم", + "NO_CATEGORIES": "لا توجد فئات متاحة", + "UNTITLED": "بدون عنوان", "MY_FAVORITES": "المفضلة", "ADD_TO_FAVORITES": "إضافة للمفضلة", "REMOVE_FROM_FAVORITES": "إزالة من المفضلة", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "تشغيل صوت الإعلان", "PROGRESS_RESET": "تمت إعادة ضبط التقدم", "HIDDEN_FROM_CONTINUE_WATCHING": "تم الإخفاء من «متابعة المشاهدة»" + }, + "SCROLL_LEFT": "التمرير لليسار", + "SCROLL_RIGHT": "التمرير لليمين", + "EPISODE_NAVIGATION": "التنقل بين الحلقات", + "INLINE_PLAYER": "المشغّل", + "REQUEST_ERRORS": { + "XTREAM": "فشل طلب Xtream: {{message}}", + "XTREAM_CONNECTION": "فشل الاتصال بخادم Xtream", + "STALKER": "فشل طلب Stalker: {{message}} (الحالة {{status}})", + "NOT_FOUND": "غير موجود" } }, "INFORMATION": "معلومات", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "فتح في قائمة التشغيل", "RAIL_ALL_CHANNELS": "جميع القنوات", "RAIL_GROUPS": "المجموعات", "RAIL_CONTEXT_ACTIONS": "إجراءات السياق", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "أدخل رمز PIN للإظهار", "HEADER_LOCK": "القفل الآن (الرقابة الأبوية)", "HEADER_UNLOCK": "أدخل رمز PIN للرقابة الأبوية" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "جارٍ الفتح في {{player}}…", + "STOP": "إيقاف {{player}}", + "ERRORS": { + "START_FAILED": "تعذّر بدء تشغيل {{player}}. تأكد من تثبيته ومن صحة مساره في الإعدادات.", + "PREVIOUS_CLOSING": "لا يزال المشغل السابق قيد الإغلاق. حاول مرة أخرى بعد لحظات.", + "CLOSED_UNEXPECTEDLY": "أُغلق {{player}} بشكل غير متوقع.", + "STREAM_OPEN_FAILED": "تعذّر على {{player}} فتح البث. قد يكون الرابط غير صالح أو أن الخادم لا يستجيب.", + "UNSUPPORTED_PROTOCOL": "لا يدعم {{player}} بروتوكول هذا البث. تحقق من رابط البث.", + "CONNECTION_FAILED": "تعذّر على {{player}} الاتصال بخادم البث. تحقق من اتصالك بالإنترنت.", + "ACCESS_DENIED": "تم رفض الوصول. قد يتطلب البث بيانات اعتماد أو ترويسات صالحة.", + "STREAM_NOT_FOUND": "البث غير موجود. قد يكون الرابط غير صحيح أو منتهي الصلاحية.", + "TIMED_OUT": "انتهت مهلة الاتصال. الخادم لا يستجيب.", + "LAUNCH_FAILED": "تعذّر بدء تشغيل {{player}}.", + "UNKNOWN": "خطأ في {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "يتم تشغيل الفيديو في نافذة المشغل الخارجي.", + "INSTALL_HINT": "تأكد من تثبيت MPV أو VLC بشكل صحيح على نظامك.", + "INSTALL_INSTRUCTIONS": "تعليمات التثبيت", + "DONT_SHOW_AGAIN": "عدم الإظهار مرة أخرى" + } } } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 84364f9ed..0371762ab 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "قائمة تشغيل Xtream", "STALKER_PORTAL": "بوابة Stalker", "CHANNELS": "القنوات", + "CHANNELS_COUNT": "{{count}} قناة", "ADDED": "تزادت", "SHOW_DETAILS": "وري تفاصيل قائمة التشغيل", "REMOVE": "حيد قائمة التشغيل", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "عنوان MAC", "PLAYLIST_EXPORT_SUCCESS": "قائمة التشغيل تصدرات بنجاح.", "PLAYLIST_EXPORT_ERROR": "خطأ: تصدير قائمة التشغيل فشل.", + "EXPORT_PLAYLIST_FAILED": "فشل تصدير قائمة التشغيل.", "COPY_URL": "نسخ الرابط للحافظة", "URL_COPIED": "الرابط تنسخ للحافظة" }, @@ -107,6 +109,10 @@ }, "UPDATED": "تحدثات", "REFRESH": "حدث قائمة التشغيل", + "REFRESH_FETCHING": "جاري جلب قائمة التشغيل…", + "REFRESH_READING_FILE": "جاري قراية ملف قائمة التشغيل…", + "REFRESH_PARSING": "جاري تحليل قائمة التشغيل…", + "REFRESH_SAVING": "جاري حفظ قائمة التشغيل…", "REFRESH_XTREAM": "حدث قائمة Xtream من السيرفر", "REFRESH_XTREAM_DIALOG": { "TITLE": "حدث قائمة Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "جميع قوائم التشغيل المتوفرة", "PLAYLIST_UPDATE_SUCCESS": "نجاح! قائمة التشغيل تحدثات بنجاح.", "PLAYLIST_UPDATE_ERROR": "خطأ في تحديث تفاصيل قائمة التشغيل.", + "PLAYLIST_UPDATE_FAILED": "ما قدرناش نحدّثو قائمة التشغيل.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "فشل تحديث قائمة التشغيل. الملف المحلي ما بقاش متوفر. تحقق من مسار الملف ولا استورد قائمة التشغيل من جديد.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "فشل تحديث قائمة التشغيل. التطبيق ما قدرش يوصل للملف المحلي.", "AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! قوائم التشغيل تحدثات بنجاح", @@ -166,6 +173,7 @@ "ERROR_403": "الوصول مرفوض (403): السيرفر رفض الطلب. يمكن الرابط محتاج مصادقة ولا قائمة التشغيل مقيدة.", "ERROR_404": "قائمة التشغيل ما تلقاتش (404): الرابط ما كيوجهش لقائمة تشغيل صحيحة. عفاك تحقق من الرابط.", "ERROR_401": "غير مصرح (401): المصادقة مطلوبة باش تدخل لهاد قائمة التشغيل.", + "ERROR_413": "هاد الملف كبير بزاف. استعمل النسخة المستقلة ديال التطبيق ولا النسخة اللي كتستضافها بنفسك.", "ERROR_INVALID_TLS": "الشهادة ديال مضيف قائمة التشغيل هادي ما صالحةش.", "ERROR_TLS_HOST_UNKNOWN": "ما قدرناش نحددو مضيف قائمة التشغيل. عفاك عاود جرّب يدوياً.", "TRUST_TLS_HOST": "وثق فالمضيف", @@ -466,6 +474,8 @@ "IMPORT_DATA": "استورد", "EXPORT_DATA": "صدّر", "EXPORTING_DATA": "جاري التصدير…", + "BACKUP_EXPORTED": "النسخة الاحتياطية ديال قوائم التشغيل تصدّرات.", + "BACKUP_EXPORT_FAILED": "فشل تصدير النسخة الاحتياطية ديال قوائم التشغيل.", "REMOVE_ALL": "حيد جميع لوائح التشغيل والمعطيات ديالهم", "REMOVE_ALL_BUTTON": "حيد الكل", "REMOVE_ALL_DESCRIPTION": "حيد كل لوائح M3U و Xtream و Stalker المخزنة فالتطبيق.", @@ -483,6 +493,7 @@ "CONFIRM": "حيد كلشي" }, "IMPORT_ERROR": "خطأ في الاستيراد، عفاك جرب بملف آخر.", + "BACKUP_IMPORT_SUMMARY": "سالا استيراد النسخة الاحتياطية: تستوردو {{imported}}، تدمجو {{merged}}، تخطّاو {{skipped}}، فشلو {{failed}}.", "PLAYLISTS_REMOVED": "تحيدو جميع لوائح التشغيل والمعطيات ديالهم.", "MPV_PLAYER_PATH_LABEL": "مسار mpv", "MPV_PLAYER_PATH_DESCRIPTION": "حدد مسار MPV. فـ macOS تقدر تستعمل باقة تطبيق MPV بحال /Applications/mpv.app أو مسار الملف التنفيذي.", @@ -623,7 +634,9 @@ "NEXT_STATION": "المحطة التالية", "MUTE": "كتم الصوت", "UNMUTE": "رجّع الصوت", - "VOLUME": "الصوت" + "VOLUME": "الصوت", + "RADIO": "راديو", + "PAUSED_BADGE": "موقوف" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "رتب بتاريخ الإضافة (الأحدث أولاً)", "SORT_BY_RATING": "رتب بالتقييم", "SORT_BY_NAME": "رتب بالاسم (أ-ي)", + "SORT_CHANNELS": "رتب القنوات", + "SORT_SERVER_ORDER": "ترتيب السيرفر", + "SORT_PLAYLIST_ORDER": "ترتيب قائمة التشغيل", "LOAD_MORE": "شارجي كثر", "REMAINING": "باقي", "LOADING": "جاري تشريج القنوات...", @@ -1132,6 +1148,7 @@ "DURATION": "المدة", "CAST": "طاقم التمثيل", "EPISODE_RUN_TIME": "مدة الحلقة", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} د/حلقة", "RATING": "التقييم", "IMDB_RATING": "تقييم IMDb", "KINOPOISK_RATING": "تقييم Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "قلب على فئات...", "NO_RESULTS": "ما تلقاتش حتى فئة مطابقة", "NO_CATEGORIES": "ما كاينش فئات متوفرة", - "SAVE": "حفظ" - } + "SAVE": "حفظ", + "SAVE_FAILED": "فشل حفظ ظهور الفئات" + }, + "GLOBAL_SEARCH_SUMMARY": "تلقاو {{count}} ديال العناصر فـ {{playlists}} ديال قوائم التشغيل" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "عاود جرّب دابا" }, "ALL_CATEGORIES": "جميع الفئات", + "ALL_ITEMS": "جميع العناصر", + "UNNAMED_CATEGORY": "فئة بدون اسم", + "NO_CATEGORIES": "ما كاينش فئات متوفرة", + "UNTITLED": "بلا عنوان", "MY_FAVORITES": "المفضلة ديالي", "ADD_TO_FAVORITES": "زيد للمفضلة", "REMOVE_FROM_FAVORITES": "حيد من المفضلة", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "خدّم صوت الإعلان", "PROGRESS_RESET": "ترجّع التقدم", "HIDDEN_FROM_CONTINUE_WATCHING": "تخبّى من «كمّل المشاهدة»" + }, + "SCROLL_LEFT": "مرّر لليسار", + "SCROLL_RIGHT": "مرّر لليمين", + "EPISODE_NAVIGATION": "التنقل بين الحلقات", + "INLINE_PLAYER": "المشغّل", + "REQUEST_ERRORS": { + "XTREAM": "فشل طلب Xtream: {{message}}", + "XTREAM_CONNECTION": "ما قدرناش نتاصلو بسيرفر Xtream", + "STALKER": "فشل طلب Stalker: {{message}} (الحالة {{status}})", + "NOT_FOUND": "ما تلقاش" } }, "INFORMATION": "معلومات", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "حلّ فقائمة التشغيل", "RAIL_ALL_CHANNELS": "جميع القنوات", "RAIL_GROUPS": "المجموعات", "RAIL_CONTEXT_ACTIONS": "إجراءات القسم", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "دخّل رمز PIN باش يبان", "HEADER_LOCK": "قفل دابا (الرقابة الأبوية)", "HEADER_UNLOCK": "دخّل رمز PIN ديال الرقابة الأبوية" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "كيتحلّ فـ {{player}}…", + "STOP": "وقّف {{player}}", + "ERRORS": { + "START_FAILED": "ما قدرناش نشعلو {{player}}. تأكد بلي مثبّت وبلي المسار ديالو فالإعدادات صحيح.", + "PREVIOUS_CLOSING": "المشغّل اللي قبل مازال كيتسدّ. عاود جرّب من بعد شوية.", + "CLOSED_UNEXPECTEDLY": "{{player}} تسدّ فجأة.", + "STREAM_OPEN_FAILED": "{{player}} ما قدرش يحلّ البث. يمكن الرابط ماشي صحيح ولا السيرفر ما كيجاوبش.", + "UNSUPPORTED_PROTOCOL": "{{player}} ما كيدعمش البروتوكول ديال هاد البث. تحقق من رابط البث.", + "CONNECTION_FAILED": "{{player}} ما قدرش يتاصل بسيرفر البث. تحقق من الاتصال ديالك بالإنترنت.", + "ACCESS_DENIED": "الوصول مرفوض. يمكن البث محتاج بيانات اعتماد ولا هيدرز صحيحين.", + "STREAM_NOT_FOUND": "البث ما تلقاش. يمكن الرابط غالط ولا سالات الصلاحية ديالو.", + "TIMED_OUT": "مهلة الاتصال سالات. السيرفر ما كيجاوبش.", + "LAUNCH_FAILED": "ما قدرناش نشعلو {{player}}.", + "UNKNOWN": "خطأ فـ {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "الفيديو خدام فنافذة المشغّل الخارجي.", + "INSTALL_HINT": "تأكد بلي MPV ولا VLC مثبّت مزيان فالنظام ديالك.", + "INSTALL_INSTRUCTIONS": "تعليمات التثبيت", + "DONT_SHOW_AGAIN": "ما توريهاش عاوتاني" + } } } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 245eecce6..c1bff9d6c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream плэйліст", "STALKER_PORTAL": "Stalker партал", "CHANNELS": "Каналаў", + "CHANNELS_COUNT": "{{count}} каналаў", "ADDED": "Дададзены", "SHOW_DETAILS": "Паказаць інфармацыю аб плэйлісце", "REMOVE": "Выдаліць плэйліст", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC-адрас", "PLAYLIST_EXPORT_SUCCESS": "Плэйліст быў паспяхова экспартаваны.", "PLAYLIST_EXPORT_ERROR": "Памылка: не ўдалося экспартаваць плэйліст.", + "EXPORT_PLAYLIST_FAILED": "Не ўдалося экспартаваць плэйліст.", "COPY_URL": "Капіяваць URL у буфер абмену", "URL_COPIED": "URL скапіраваны ў буфер абмену" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Абноўлены", "REFRESH": "Абнавіць плэйліст", + "REFRESH_FETCHING": "Атрыманне плэйліста…", + "REFRESH_READING_FILE": "Чытанне файла плэйліста…", + "REFRESH_PARSING": "Разбор плэйліста…", + "REFRESH_SAVING": "Захаванне плэйліста…", "REFRESH_XTREAM": "Абнавіць плэйліст Xtream з выдаленага сервера", "REFRESH_XTREAM_DIALOG": { "TITLE": "Абнавіць плэйліст Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "Усе наяўныя плэйлісты", "PLAYLIST_UPDATE_SUCCESS": "Плэйліст быў паспяхова абноўлены!", "PLAYLIST_UPDATE_ERROR": "Памылка пры абнаўленні дэталяў плэйліста.", + "PLAYLIST_UPDATE_FAILED": "Не ўдалося абнавіць плэйліст.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Не ўдалося абнавіць плэйліст. Лакальны файл больш недаступны. Праверце шлях да файла або паўторна імпартуйце плэйліст.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Не ўдалося абнавіць плэйліст. Праграма больш не мае доступу да лакальнага файла.", "AUTO_REFRESH_UPDATE_SUCCESS": "Гатова! Плэйлісты былі паспяхова абноўлены", @@ -166,6 +173,7 @@ "ERROR_403": "Доступ забаронены (403): сервер адхіліў запыт. URL можа патрабаваць аўтэнтыфікацыі або плэйліст абмежаваны.", "ERROR_404": "Плэйліст не знойдзены (404): URL не паказвае на сапраўдны плэйліст. Калі ласка, праверце URL.", "ERROR_401": "Несанкцыянавана (401): для доступу да гэтага плэйліста патрабуецца аўтэнтыфікацыя.", + "ERROR_413": "Гэты файл занадта вялікі. Скарыстайцеся настольнай версіяй праграмы або версіяй на ўласным серверы.", "ERROR_INVALID_TLS": "Сертыфікат хоста гэтага плэйліста несапраўдны.", "ERROR_TLS_HOST_UNKNOWN": "Не ўдалося вызначыць хост плэйліста. Паспрабуйце паўтарыць уручную.", "TRUST_TLS_HOST": "Давяраць хосту", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Імпартаваць", "EXPORT_DATA": "Экспартаваць", "EXPORTING_DATA": "Экспартаванне...", + "BACKUP_EXPORTED": "Рэзервовая копія плэйлістоў экспартавана.", + "BACKUP_EXPORT_FAILED": "Не ўдалося экспартаваць рэзервовую копію плэйлістоў.", "REMOVE_ALL": "Выдаліць усе плэйлісты і іх даныя", "REMOVE_ALL_BUTTON": "Выдаліць усё", "REMOVE_ALL_DESCRIPTION": "Выдаліць усе плэйлісты M3U, Xtream і Stalker, захаваныя ў праграме.", @@ -483,6 +493,7 @@ "CONFIRM": "Выдаліць усё" }, "IMPORT_ERROR": "Памылка імпарту, калі ласка, паспрабуйце імпартаваць іншы файл.", + "BACKUP_IMPORT_SUMMARY": "Імпарт рэзервовай копіі завершаны: імпартавана — {{imported}}, аб'яднана — {{merged}}, прапушчана — {{skipped}}, не ўдалося — {{failed}}.", "PLAYLISTS_REMOVED": "Усе плэйлісты і іх даныя былі выдалены.", "MPV_PLAYER_PATH_LABEL": "Шлях да mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Укажыце шлях да MPV. На macOS можна выкарыстоўваць пакет праграмы MPV, напрыклад /Applications/mpv.app, або шлях да выканальнага файла.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Наступная станцыя", "MUTE": "Адключыць гук", "UNMUTE": "Уключыць гук", - "VOLUME": "Гучнасць" + "VOLUME": "Гучнасць", + "RADIO": "Радыё", + "PAUSED_BADGE": "ПАЎЗА" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Сартаваць па даце дадання (спачатку новыя)", "SORT_BY_RATING": "Сартаваць па рэйтынгу", "SORT_BY_NAME": "Сартаваць па назве (А-Я)", + "SORT_CHANNELS": "Сартаваць каналы", + "SORT_SERVER_ORDER": "Парадак сервера", + "SORT_PLAYLIST_ORDER": "Парадак плэйліста", "LOAD_MORE": "Загрузіць яшчэ", "REMAINING": "засталося", "LOADING": "Загрузка каналаў...", @@ -1132,6 +1148,7 @@ "DURATION": "Працягласць", "CAST": "Акцёрскі склад", "EPISODE_RUN_TIME": "Працягласць эпізоду", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} хв/эпізод", "RATING": "Рэйтынг", "IMDB_RATING": "Рэйтынг IMDb", "KINOPOISK_RATING": "Рэйтынг Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Пошук катэгорый...", "NO_RESULTS": "Адпаведных катэгорый не знойдзена", "NO_CATEGORIES": "Няма даступных катэгорый", - "SAVE": "Захаваць" - } + "SAVE": "Захаваць", + "SAVE_FAILED": "Не ўдалося захаваць бачнасць катэгорый" + }, + "GLOBAL_SEARCH_SUMMARY": "Знойдзена {{count}} элементаў у {{playlists}} плэйлістах" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Паўтарыць зараз" }, "ALL_CATEGORIES": "Усе катэгорыі", + "ALL_ITEMS": "Усе элементы", + "UNNAMED_CATEGORY": "Катэгорыя без назвы", + "NO_CATEGORIES": "Няма даступных катэгорый", + "UNTITLED": "Без назвы", "MY_FAVORITES": "Выбранае", "ADD_TO_FAVORITES": "Дадаць у выбранае", "REMOVE_FROM_FAVORITES": "Выдаліць з выбранага", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Уключыць гук трэйлера", "PROGRESS_RESET": "Прагрэс скінуты", "HIDDEN_FROM_CONTINUE_WATCHING": "Схавана з «Працягнуць прагляд»" + }, + "SCROLL_LEFT": "Пракруціць налева", + "SCROLL_RIGHT": "Пракруціць направа", + "EPISODE_NAVIGATION": "Навігацыя па эпізодах", + "INLINE_PLAYER": "Плэер", + "REQUEST_ERRORS": { + "XTREAM": "Памылка запыту Xtream: {{message}}", + "XTREAM_CONNECTION": "Не ўдалося падключыцца да сервера Xtream", + "STALKER": "Памылка запыту Stalker: {{message}} (статус {{status}})", + "NOT_FOUND": "Не знойдзена" } }, "INFORMATION": "Інфармацыя", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Адкрыць у плэйлісце", "RAIL_ALL_CHANNELS": "Усе каналы", "RAIL_GROUPS": "Групы", "RAIL_CONTEXT_ACTIONS": "Кантэкстныя дзеянні", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Увядзіце PIN, каб паказаць", "HEADER_LOCK": "Заблакіраваць зараз (бацькоўскі кантроль)", "HEADER_UNLOCK": "Увядзіце бацькоўскі PIN" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Адкрыццё ў {{player}}…", + "STOP": "Спыніць {{player}}", + "ERRORS": { + "START_FAILED": "Не ўдалося запусціць {{player}}. Пераканайцеся, што ён усталяваны і шлях да яго ў наладах указаны правільна.", + "PREVIOUS_CLOSING": "Папярэдні плэер яшчэ закрываецца. Паспрабуйце яшчэ раз крыху пазней.", + "CLOSED_UNEXPECTEDLY": "{{player}} нечакана закрыўся.", + "STREAM_OPEN_FAILED": "{{player}} не змог адкрыць паток. Магчыма, URL няправільны або сервер не адказвае.", + "UNSUPPORTED_PROTOCOL": "{{player}} не падтрымлівае пратакол гэтага патоку. Праверце URL патоку.", + "CONNECTION_FAILED": "{{player}} не можа падключыцца да сервера патоку. Праверце падключэнне да інтэрнэту.", + "ACCESS_DENIED": "Доступ забаронены. Магчыма, паток патрабуе сапраўдных уліковых даных або загалоўкаў.", + "STREAM_NOT_FOUND": "Паток не знойдзены. Магчыма, URL няправільны або больш не дзейнічае.", + "TIMED_OUT": "Час чакання злучэння скончыўся. Сервер не адказвае.", + "LAUNCH_FAILED": "Не ўдалося запусціць {{player}}.", + "UNKNOWN": "Памылка {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "Відэа прайграецца ў акне знешняга плэера.", + "INSTALL_HINT": "Пераканайцеся, што MPV або VLC правільна ўсталяваны ў вашай сістэме.", + "INSTALL_INSTRUCTIONS": "Інструкцыі па ўсталяванні", + "DONT_SHOW_AGAIN": "Больш не паказваць" + } } } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 0fba0c5ce..0cff6db95 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream-Playlist", "STALKER_PORTAL": "Stalker-Portal", "CHANNELS": "TV-Sender", + "CHANNELS_COUNT": "{{count}} Sender", "ADDED": "Hinzugefügt am", "SHOW_DETAILS": "Details anzeigen", "REMOVE": "Playlist entfernen", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC-Adresse", "PLAYLIST_EXPORT_SUCCESS": "Playlist wurde erfolgreich exportiert.", "PLAYLIST_EXPORT_ERROR": "Fehler: Playlist-Export fehlgeschlagen.", + "EXPORT_PLAYLIST_FAILED": "Playlist-Export fehlgeschlagen.", "COPY_URL": "URL in Zwischenablage kopieren", "URL_COPIED": "URL in die Zwischenablage kopiert" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Aktualisiert am", "REFRESH": "Aktualisieren", + "REFRESH_FETCHING": "Playlist wird abgerufen…", + "REFRESH_READING_FILE": "Playlist-Datei wird gelesen…", + "REFRESH_PARSING": "Playlist wird verarbeitet…", + "REFRESH_SAVING": "Playlist wird gespeichert…", "REFRESH_XTREAM": "Xtream-Playlist vom Server aktualisieren", "REFRESH_XTREAM_DIALOG": { "TITLE": "Xtream-Playlist aktualisieren", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "Alle verfügbaren Playlisten", "PLAYLIST_UPDATE_SUCCESS": "Erfolg! Die Playlist wurde erfolgreich aktualisiert.", "PLAYLIST_UPDATE_ERROR": "Fehler beim Aktualisieren der Playlist-Details.", + "PLAYLIST_UPDATE_FAILED": "Die Playlist konnte nicht aktualisiert werden.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Aktualisierung der Playlist fehlgeschlagen. Die lokale Datei ist nicht mehr verfügbar. Überprüfen Sie den Dateipfad oder importieren Sie die Playlist erneut.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Aktualisierung der Playlist fehlgeschlagen. Die App kann nicht mehr auf die lokale Datei zugreifen.", "AUTO_REFRESH_UPDATE_SUCCESS": "Erfolg! Die Playlists wurden erfolgreich aktualisiert", @@ -166,6 +173,7 @@ "ERROR_403": "Zugriff verweigert (403): Der Server hat die Anfrage abgelehnt. Die URL erfordert möglicherweise eine Authentifizierung oder die Playlist ist eingeschränkt.", "ERROR_404": "Playlist nicht gefunden (404): Die URL verweist nicht auf eine gültige Playlist. Bitte überprüfen Sie die URL.", "ERROR_401": "Nicht autorisiert (401): Für den Zugriff auf diese Playlist ist eine Authentifizierung erforderlich.", + "ERROR_413": "Diese Datei ist zu groß. Verwenden Sie die eigenständige oder selbst gehostete Version der App.", "ERROR_INVALID_TLS": "Das Zertifikat für diesen Wiedergabelisten-Host ist ungültig.", "ERROR_TLS_HOST_UNKNOWN": "Der Wiedergabelisten-Host konnte nicht ermittelt werden. Bitte versuche es manuell erneut.", "TRUST_TLS_HOST": "Host vertrauen", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importieren", "EXPORT_DATA": "Exportieren", "EXPORTING_DATA": "Wird exportiert …", + "BACKUP_EXPORTED": "Playlist-Backup exportiert.", + "BACKUP_EXPORT_FAILED": "Export des Playlist-Backups fehlgeschlagen.", "REMOVE_ALL": "Alle Wiedergabelisten und Wiedergabelistendaten löschen", "REMOVE_ALL_BUTTON": "Alle löschen", "REMOVE_ALL_DESCRIPTION": "Alle in der App gespeicherten M3U-, Xtream- und Stalker-Wiedergabelisten löschen.", @@ -483,6 +493,7 @@ "CONFIRM": "Alles löschen" }, "IMPORT_ERROR": "Importfehler. Bitte versuchen Sie es mit einer anderen Datei.", + "BACKUP_IMPORT_SUMMARY": "Backup-Import abgeschlossen: {{imported}} importiert, {{merged}} zusammengeführt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen.", "PLAYLISTS_REMOVED": "Alle Wiedergabelisten und Wiedergabelistendaten wurden entfernt.", "MPV_PLAYER_PATH_LABEL": "mpv-Pfad", "MPV_PLAYER_PATH_DESCRIPTION": "Lege den Pfad zu MPV fest. Unter macOS kannst du das MPV-App-Bundle wie /Applications/mpv.app oder den ausführbaren Pfad verwenden.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Nächster Sender", "MUTE": "Stummschalten", "UNMUTE": "Stummschaltung aufheben", - "VOLUME": "Lautstärke" + "VOLUME": "Lautstärke", + "RADIO": "Radiosender", + "PAUSED_BADGE": "PAUSIERT" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Nach Datum sortieren (Neueste zuerst)", "SORT_BY_RATING": "Nach Bewertung sortieren", "SORT_BY_NAME": "Nach Name sortieren (A-Z)", + "SORT_CHANNELS": "Sender sortieren", + "SORT_SERVER_ORDER": "Serverreihenfolge", + "SORT_PLAYLIST_ORDER": "Playlist-Reihenfolge", "LOAD_MORE": "Mehr laden", "REMAINING": "verbleibend", "LOADING": "Sender werden geladen …", @@ -1132,6 +1148,7 @@ "DURATION": "Dauer", "CAST": "Besetzung", "EPISODE_RUN_TIME": "Episodenlaufzeit", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} Min./Folge", "RATING": "Bewertung", "IMDB_RATING": "IMDb-Bewertung", "KINOPOISK_RATING": "Kinopoisk-Bewertung", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Kategorien suchen …", "NO_RESULTS": "Keine passenden Kategorien gefunden", "NO_CATEGORIES": "Keine Kategorien verfügbar", - "SAVE": "Speichern" - } + "SAVE": "Speichern", + "SAVE_FAILED": "Sichtbarkeit der Kategorien konnte nicht gespeichert werden" + }, + "GLOBAL_SEARCH_SUMMARY": "Ergebnisse: {{count}} · Playlists: {{playlists}}" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Jetzt erneut versuchen" }, "ALL_CATEGORIES": "Alle Kategorien", + "ALL_ITEMS": "Alle Elemente", + "UNNAMED_CATEGORY": "Unbenannte Kategorie", + "NO_CATEGORIES": "Keine Kategorien verfügbar", + "UNTITLED": "Ohne Titel", "MY_FAVORITES": "Meine Favoriten", "ADD_TO_FAVORITES": "Zu den Favoriten hinzufügen", "REMOVE_FROM_FAVORITES": "Aus Favoriten entfernen", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Trailer-Ton einschalten", "PROGRESS_RESET": "Fortschritt zurückgesetzt", "HIDDEN_FROM_CONTINUE_WATCHING": "Aus „Weiterschauen“ ausgeblendet" + }, + "SCROLL_LEFT": "Nach links scrollen", + "SCROLL_RIGHT": "Nach rechts scrollen", + "EPISODE_NAVIGATION": "Episodennavigation", + "INLINE_PLAYER": "Videoplayer", + "REQUEST_ERRORS": { + "XTREAM": "Xtream-Anfrage fehlgeschlagen: {{message}}", + "XTREAM_CONNECTION": "Verbindung zum Xtream-Server fehlgeschlagen", + "STALKER": "Stalker-Anfrage fehlgeschlagen: {{message}} (Status {{status}})", + "NOT_FOUND": "Nicht gefunden" } }, "INFORMATION": "Information", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Wiedergabe läuft", "EXTERNAL_PLAYBACK_FAILED": "Fehler im externen Player", "EXTERNAL_PLAYBACK_CLOSED": "Geschlossen", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "In Playlist öffnen", "RAIL_ALL_CHANNELS": "Alle Sender", "RAIL_GROUPS": "Gruppen", "RAIL_CONTEXT_ACTIONS": "Abschnittsaktionen", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "PIN eingeben zum Anzeigen", "HEADER_LOCK": "Jetzt sperren (Kindersicherung)", "HEADER_UNLOCK": "Kindersicherungs-PIN eingeben" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Wird in {{player}} geöffnet…", + "STOP": "{{player}} stoppen", + "ERRORS": { + "START_FAILED": "{{player}} konnte nicht gestartet werden. Stellen Sie sicher, dass der Player installiert und sein Pfad in den Einstellungen korrekt ist.", + "PREVIOUS_CLOSING": "Der vorherige Player wird noch geschlossen. Versuchen Sie es gleich noch einmal.", + "CLOSED_UNEXPECTEDLY": "{{player}} wurde unerwartet beendet.", + "STREAM_OPEN_FAILED": "{{player}} konnte den Stream nicht öffnen. Die URL ist möglicherweise ungültig oder der Server antwortet nicht.", + "UNSUPPORTED_PROTOCOL": "{{player}} unterstützt dieses Stream-Protokoll nicht. Prüfen Sie die Stream-URL.", + "CONNECTION_FAILED": "{{player}} kann keine Verbindung zum Stream-Server herstellen. Prüfen Sie Ihre Internetverbindung.", + "ACCESS_DENIED": "Zugriff verweigert. Der Stream erfordert möglicherweise gültige Zugangsdaten oder Header.", + "STREAM_NOT_FOUND": "Stream nicht gefunden. Die URL ist möglicherweise falsch oder abgelaufen.", + "TIMED_OUT": "Zeitüberschreitung der Verbindung. Der Server antwortet nicht.", + "LAUNCH_FAILED": "{{player}} konnte nicht gestartet werden.", + "UNKNOWN": "{{player}}-Fehler: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "Das Video wird im Fenster des externen Players wiedergegeben.", + "INSTALL_HINT": "Stellen Sie sicher, dass MPV oder VLC korrekt auf Ihrem System installiert ist.", + "INSTALL_INSTRUCTIONS": "Installationsanleitung", + "DONT_SHOW_AGAIN": "Nicht mehr anzeigen" + } } } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 435a4cb76..41f2a1954 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream playlist", "STALKER_PORTAL": "Stalker portal", "CHANNELS": "Κανάλια", + "CHANNELS_COUNT": "Κανάλια: {{count}}", "ADDED": "Προστέθηκε", "SHOW_DETAILS": "Λεπτομέρειες λίστας αναπαραγωγής", "REMOVE": "Διαγραφή λίστας αναπαραγωγής", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "Διεύθυνση MAC", "PLAYLIST_EXPORT_SUCCESS": "Η εξαγωγή της λίστας αναπαραγωγής ολοκληρώθηκε με επιτυχία.", "PLAYLIST_EXPORT_ERROR": "Σφάλμα: Η εξαγωγή της λίστας αναπαραγωγής απέτυχε.", + "EXPORT_PLAYLIST_FAILED": "Η εξαγωγή της λίστας αναπαραγωγής απέτυχε.", "COPY_URL": "Αντιγραφή διεύθηνσης URL στο πρόχειρο", "URL_COPIED": "Η διεύθυνση URL αντιγράφηκε στο πρόχειρο" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Ενημερώθηκε", "REFRESH": "Ανανέωση λίστας αναπαραγωγής", + "REFRESH_FETCHING": "Λήψη λίστας αναπαραγωγής…", + "REFRESH_READING_FILE": "Ανάγνωση αρχείου λίστας αναπαραγωγής…", + "REFRESH_PARSING": "Ανάλυση λίστας αναπαραγωγής…", + "REFRESH_SAVING": "Αποθήκευση λίστας αναπαραγωγής…", "REFRESH_XTREAM": "Ανανέωση Xtream λίστας από το τηλεκοντρόλ", "REFRESH_XTREAM_DIALOG": { "TITLE": "Ανανέωση Xtream λίστας", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "όλες οι διαθέσιμες λίστες αναπαραγωγής", "PLAYLIST_UPDATE_SUCCESS": "Επιτυχία! Η λίστα αναπαραγωγής ενημερώθηκε με επιτυχία.", "PLAYLIST_UPDATE_ERROR": "Σφάλμα κατά την ενημέρωση των λεπτομερειών της λίστας αναπαραγωγής.", + "PLAYLIST_UPDATE_FAILED": "Δεν ήταν δυνατή η ενημέρωση της λίστας αναπαραγωγής.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Το τοπικό αρχείο δεν είναι πλέον διαθέσιμο. Ελέγξτε τη διαδρομή του αρχείου ή εισαγάγετε ξανά τη λίστα αναπαραγωγής.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Η εφαρμογή δεν έχει πλέον πρόσβαση στο τοπικό αρχείο.", "AUTO_REFRESH_UPDATE_SUCCESS": "Επιτυχία! Οι λίστες αναπαραγωγής ενημερώθηκαν με επιτυχία", @@ -166,6 +173,7 @@ "ERROR_403": "Άρνηση πρόσβασης (403): Ο διακομιστής απέρριψε το αίτημα. Η διεύθυνση URL μπορεί να απαιτεί έλεγχο ταυτότητας ή η λίστα αναπαραγωγής είναι περιορισμένη.", "ERROR_404": "Η λίστα αναπαραγωγής δεν βρέθηκε (404): Η διεύθυνση URL δεν δείχνει σε έγκυρη λίστα αναπαραγωγής. Ελέγξτε τη διεύθυνση URL.", "ERROR_401": "Μη εξουσιοδοτημένος (401): Απαιτείται έλεγχος ταυτότητας για πρόσβαση σε αυτή τη λίστα αναπαραγωγής.", + "ERROR_413": "Το αρχείο είναι πολύ μεγάλο. Χρησιμοποιήστε την αυτόνομη έκδοση της εφαρμογής ή μια έκδοση σε δικό σας διακομιστή.", "ERROR_INVALID_TLS": "Το πιστοποιητικό αυτού του host λίστας αναπαραγωγής δεν είναι έγκυρο.", "ERROR_TLS_HOST_UNKNOWN": "Δεν ήταν δυνατός ο προσδιορισμός του host της λίστας αναπαραγωγής. Δοκιμάστε ξανά χειροκίνητα.", "TRUST_TLS_HOST": "Εμπιστοσύνη στον host", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Εισαγωγή", "EXPORT_DATA": "Εξαγωγή", "EXPORTING_DATA": "Εξαγωγή…", + "BACKUP_EXPORTED": "Η εξαγωγή του αντιγράφου ασφαλείας λιστών αναπαραγωγής ολοκληρώθηκε.", + "BACKUP_EXPORT_FAILED": "Η εξαγωγή του αντιγράφου ασφαλείας λιστών αναπαραγωγής απέτυχε.", "REMOVE_ALL": "Διαγραφή όλων των λιστών αναπαραγωγής και των δεδομένων τους", "REMOVE_ALL_BUTTON": "Διαγραφή όλων", "REMOVE_ALL_DESCRIPTION": "Διαγραφή κάθε λίστας M3U, Xtream και Stalker που είναι αποθηκευμένη στην εφαρμογή.", @@ -483,6 +493,7 @@ "CONFIRM": "Διαγραφή όλων" }, "IMPORT_ERROR": "Σφάλμα εισαγωγής, δοκιμάστε με άλλο αρχείο.", + "BACKUP_IMPORT_SUMMARY": "Η εισαγωγή αντιγράφου ασφαλείας ολοκληρώθηκε: εισήχθησαν {{imported}}, συγχωνεύτηκαν {{merged}}, παραλείφθηκαν {{skipped}}, απέτυχαν {{failed}}.", "PLAYLISTS_REMOVED": "Όλες οι λίστες αναπαραγωγής και τα δεδομένα τους αφαιρέθηκαν.", "MPV_PLAYER_PATH_LABEL": "Διαδρομή mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Ορίστε τη διαδρομή προς το MPV. Στο macOS μπορείτε να χρησιμοποιήσετε το πακέτο εφαρμογής MPV, όπως /Applications/mpv.app, ή τη διαδρομή του εκτελέσιμου αρχείου.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Επόμενος σταθμός", "MUTE": "Σίγαση", "UNMUTE": "Κατάργηση σίγασης", - "VOLUME": "Ένταση" + "VOLUME": "Ένταση", + "RADIO": "Ραδιόφωνο", + "PAUSED_BADGE": "ΠΑΥΣΗ" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Ταξινόμηση κατά Ημερομηνία Προσθήκης (Πρώτα τα Νεότερα)", "SORT_BY_RATING": "Ταξινόμηση κατά Βαθμολογία", "SORT_BY_NAME": "Ταξινόμηση κατά Όνομα (A-Z)", + "SORT_CHANNELS": "Ταξινόμηση καναλιών", + "SORT_SERVER_ORDER": "Σειρά διακομιστή", + "SORT_PLAYLIST_ORDER": "Σειρά λίστας αναπαραγωγής", "LOAD_MORE": "Φόρτωση περισσότερων", "REMAINING": "απομένουν", "LOADING": "Φόρτωση καναλιών...", @@ -1132,6 +1148,7 @@ "DURATION": "Διάρκεια", "CAST": "Ηθοποιοί", "EPISODE_RUN_TIME": "Διάρκεια επεισοδίου", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} λεπ./επεισ.", "RATING": "Βαθμολογία", "IMDB_RATING": "Βαθμολογία IMDb", "KINOPOISK_RATING": "Βαθμολογία Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Αναζήτηση κατηγοριών...", "NO_RESULTS": "Δεν βρέθηκαν αντίστοιχες κατηγορίες", "NO_CATEGORIES": "Δεν υπάρχουν διαθέσιμες κατηγορίες", - "SAVE": "Αποθήκευση" - } + "SAVE": "Αποθήκευση", + "SAVE_FAILED": "Αποτυχία αποθήκευσης της ορατότητας κατηγοριών" + }, + "GLOBAL_SEARCH_SUMMARY": "Αποτελέσματα: {{count}} · Λίστες αναπαραγωγής: {{playlists}}" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Νέα προσπάθεια τώρα" }, "ALL_CATEGORIES": "Όλες οι κατηγορίες", + "ALL_ITEMS": "Όλα τα στοιχεία", + "UNNAMED_CATEGORY": "Κατηγορία χωρίς όνομα", + "NO_CATEGORIES": "Δεν υπάρχουν διαθέσιμες κατηγορίες", + "UNTITLED": "Χωρίς τίτλο", "MY_FAVORITES": "Τα αγαπημένα μου", "ADD_TO_FAVORITES": "Προσθήκη στα αγαπημένα", "REMOVE_FROM_FAVORITES": "Αφαίρεση από τα αγαπημένα", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Ενεργοποίηση ήχου τρέιλερ", "PROGRESS_RESET": "Η πρόοδος επαναφέρθηκε", "HIDDEN_FROM_CONTINUE_WATCHING": "Αποκρύφθηκε από το «Συνέχεια παρακολούθησης»" + }, + "SCROLL_LEFT": "Κύλιση αριστερά", + "SCROLL_RIGHT": "Κύλιση δεξιά", + "EPISODE_NAVIGATION": "Πλοήγηση επεισοδίων", + "INLINE_PLAYER": "Πρόγραμμα αναπαραγωγής", + "REQUEST_ERRORS": { + "XTREAM": "Το αίτημα Xtream απέτυχε: {{message}}", + "XTREAM_CONNECTION": "Αποτυχία σύνδεσης με τον διακομιστή Xtream", + "STALKER": "Το αίτημα Stalker απέτυχε: {{message}} (κατάσταση {{status}})", + "NOT_FOUND": "Δεν βρέθηκε" } }, "INFORMATION": "Πληροφορίες", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Άνοιγμα στη λίστα αναπαραγωγής", "RAIL_ALL_CHANNELS": "Όλα τα κανάλια", "RAIL_GROUPS": "Ομάδες", "RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Εισαγάγετε PIN για εμφάνιση", "HEADER_LOCK": "Κλείδωμα τώρα (γονικός έλεγχος)", "HEADER_UNLOCK": "Εισαγωγή γονικού PIN" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Άνοιγμα στο {{player}}…", + "STOP": "Διακοπή {{player}}", + "ERRORS": { + "START_FAILED": "Δεν ήταν δυνατή η εκκίνηση του {{player}}. Βεβαιωθείτε ότι είναι εγκατεστημένο και ότι η διαδρομή του στις Ρυθμίσεις είναι σωστή.", + "PREVIOUS_CLOSING": "Το προηγούμενο πρόγραμμα αναπαραγωγής κλείνει ακόμη. Δοκιμάστε ξανά σε λίγο.", + "CLOSED_UNEXPECTEDLY": "Το {{player}} έκλεισε απροσδόκητα.", + "STREAM_OPEN_FAILED": "Το {{player}} δεν μπόρεσε να ανοίξει τη ροή. Η διεύθυνση URL μπορεί να μην είναι έγκυρη ή ο διακομιστής δεν αποκρίνεται.", + "UNSUPPORTED_PROTOCOL": "Το {{player}} δεν υποστηρίζει αυτό το πρωτόκολλο ροής. Ελέγξτε τη διεύθυνση URL της ροής.", + "CONNECTION_FAILED": "Το {{player}} δεν μπορεί να συνδεθεί με τον διακομιστή της ροής. Ελέγξτε τη σύνδεσή σας στο διαδίκτυο.", + "ACCESS_DENIED": "Άρνηση πρόσβασης. Η ροή μπορεί να απαιτεί έγκυρα διαπιστευτήρια ή κεφαλίδες.", + "STREAM_NOT_FOUND": "Η ροή δεν βρέθηκε. Η διεύθυνση URL μπορεί να είναι λανθασμένη ή να έχει λήξει.", + "TIMED_OUT": "Λήξη χρονικού ορίου σύνδεσης. Ο διακομιστής δεν αποκρίνεται.", + "LAUNCH_FAILED": "Δεν ήταν δυνατή η εκκίνηση του {{player}}.", + "UNKNOWN": "Σφάλμα {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "Το βίντεο αναπαράγεται στο παράθυρο του εξωτερικού προγράμματος αναπαραγωγής.", + "INSTALL_HINT": "Βεβαιωθείτε ότι το MPV ή το VLC είναι σωστά εγκατεστημένο στο σύστημά σας.", + "INSTALL_INSTRUCTIONS": "Οδηγίες εγκατάστασης", + "DONT_SHOW_AGAIN": "Να μην εμφανιστεί ξανά" + } } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index bec118552..3e4c35f3c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream playlist", "STALKER_PORTAL": "Stalker portal", "CHANNELS": "Channels", + "CHANNELS_COUNT": "{{count}} channels", "ADDED": "Added", "SHOW_DETAILS": "Show playlist details", "REMOVE": "Remove playlist", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC address", "PLAYLIST_EXPORT_SUCCESS": "Playlist was exported successfully.", "PLAYLIST_EXPORT_ERROR": "Error: Playlist export failed.", + "EXPORT_PLAYLIST_FAILED": "Playlist export failed.", "COPY_URL": "Copy URL to clipboard", "URL_COPIED": "URL copied to clipboard" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Updated", "REFRESH": "Refresh playlist", + "REFRESH_FETCHING": "Fetching playlist…", + "REFRESH_READING_FILE": "Reading playlist file…", + "REFRESH_PARSING": "Parsing playlist…", + "REFRESH_SAVING": "Saving playlist…", "REFRESH_XTREAM": "Refresh Xtream playlist from remote", "REFRESH_XTREAM_DIALOG": { "TITLE": "Refresh Xtream Playlist", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "all available playlists", "PLAYLIST_UPDATE_SUCCESS": "Success! The playlist was successfully updated.", "PLAYLIST_UPDATE_ERROR": "Error updating playlist details.", + "PLAYLIST_UPDATE_FAILED": "Couldn't update the playlist.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Playlist refresh failed. The app can no longer access the local file.", "AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated", @@ -166,6 +173,7 @@ "ERROR_403": "Access denied (403): The server refused the request. The URL may require authentication or the playlist is restricted.", "ERROR_404": "Playlist not found (404): The URL does not point to a valid playlist. Please check the URL.", "ERROR_401": "Unauthorized (401): Authentication is required to access this playlist.", + "ERROR_413": "This file is too big. Use the standalone or self-hosted version of the app.", "ERROR_INVALID_TLS": "Certificate for this playlist host is invalid.", "ERROR_TLS_HOST_UNKNOWN": "Could not determine the playlist host. Please retry manually.", "TRUST_TLS_HOST": "Trust host", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Import", "EXPORT_DATA": "Export", "EXPORTING_DATA": "Exporting…", + "BACKUP_EXPORTED": "Playlist backup exported.", + "BACKUP_EXPORT_FAILED": "Playlist backup export failed.", "REMOVE_ALL": "Delete all playlists and playlist data", "REMOVE_ALL_BUTTON": "Delete all", "REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.", @@ -483,6 +493,7 @@ "CONFIRM": "Delete everything" }, "IMPORT_ERROR": "Import error, please try with another file.", + "BACKUP_IMPORT_SUMMARY": "Backup import finished: {{imported}} imported, {{merged}} merged, {{skipped}} skipped, {{failed}} failed.", "PLAYLISTS_REMOVED": "All playlists and playlist data were removed.", "MPV_PLAYER_PATH_LABEL": "mpv path", "MPV_PLAYER_PATH_DESCRIPTION": "Set the path to MPV. On macOS you can use the MPV app bundle, such as /Applications/mpv.app, or the executable path.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Next station", "MUTE": "Mute", "UNMUTE": "Unmute", - "VOLUME": "Volume" + "VOLUME": "Volume", + "RADIO": "Radio", + "PAUSED_BADGE": "PAUSED" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Sort by Date Added (Newest First)", "SORT_BY_RATING": "Sort by Rating", "SORT_BY_NAME": "Sort by Name (A-Z)", + "SORT_CHANNELS": "Sort channels", + "SORT_SERVER_ORDER": "Server order", + "SORT_PLAYLIST_ORDER": "Playlist order", "LOAD_MORE": "Load more", "REMAINING": "remaining", "LOADING": "Loading channels...", @@ -1132,6 +1148,7 @@ "DURATION": "Duration", "CAST": "Cast", "EPISODE_RUN_TIME": "Episode run time", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} min/ep", "RATING": "Rating", "IMDB_RATING": " Rating IMDB", "KINOPOISK_RATING": "Rating Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Search categories...", "NO_RESULTS": "No matching categories found", "NO_CATEGORIES": "No categories available", - "SAVE": "Save" - } + "SAVE": "Save", + "SAVE_FAILED": "Failed to save category visibility" + }, + "GLOBAL_SEARCH_SUMMARY": "Found {{count}} items across {{playlists}} playlists" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Retry now" }, "ALL_CATEGORIES": "All categories", + "ALL_ITEMS": "All items", + "UNNAMED_CATEGORY": "Unnamed category", + "NO_CATEGORIES": "No categories available", + "UNTITLED": "Untitled", "MY_FAVORITES": "My favorites", "ADD_TO_FAVORITES": "Add to favorites", "REMOVE_FROM_FAVORITES": "Remove from favorites", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Unmute trailer", "PROGRESS_RESET": "Progress reset", "HIDDEN_FROM_CONTINUE_WATCHING": "Hidden from Continue Watching" + }, + "SCROLL_LEFT": "Scroll left", + "SCROLL_RIGHT": "Scroll right", + "EPISODE_NAVIGATION": "Episode navigation", + "INLINE_PLAYER": "Player", + "REQUEST_ERRORS": { + "XTREAM": "Xtream request failed: {{message}}", + "XTREAM_CONNECTION": "Failed to connect to Xtream server", + "STALKER": "Stalker request failed: {{message}} (status {{status}})", + "NOT_FOUND": "Not found" } }, "INFORMATION": "Information", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Open in playlist", "RAIL_ALL_CHANNELS": "All channels", "RAIL_GROUPS": "Groups", "RAIL_CONTEXT_ACTIONS": "Section actions", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Enter PIN to show", "HEADER_LOCK": "Lock now (parental lock)", "HEADER_UNLOCK": "Enter parental PIN" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Opening in {{player}}…", + "STOP": "Stop {{player}}", + "ERRORS": { + "START_FAILED": "Could not start {{player}}. Make sure it is installed and its path in Settings is correct.", + "PREVIOUS_CLOSING": "The previous player is still closing. Try again in a moment.", + "CLOSED_UNEXPECTEDLY": "{{player}} closed unexpectedly.", + "STREAM_OPEN_FAILED": "{{player}} could not open the stream. The URL may be invalid or the server is not responding.", + "UNSUPPORTED_PROTOCOL": "{{player}} does not support this stream protocol. Check the stream URL.", + "CONNECTION_FAILED": "{{player}} cannot connect to the stream server. Check your internet connection.", + "ACCESS_DENIED": "Access denied. The stream may require valid credentials or headers.", + "STREAM_NOT_FOUND": "Stream not found. The URL may be incorrect or expired.", + "TIMED_OUT": "Connection timed out. The server is not responding.", + "LAUNCH_FAILED": "Could not start {{player}}.", + "UNKNOWN": "{{player}} error: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "The video is playing in the external player window.", + "INSTALL_HINT": "Make sure that MPV or VLC is installed correctly on your system.", + "INSTALL_INSTRUCTIONS": "Installation instructions", + "DONT_SHOW_AGAIN": "Don't show again" + } } } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3ebff226b..7f09ab415 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Lista Xtream", "STALKER_PORTAL": "Stalker Portal", "CHANNELS": "Canales", + "CHANNELS_COUNT": "Canales: {{count}}", "ADDED": "Añadido", "SHOW_DETAILS": "Mostrar detalles de la lista", "REMOVE": "Quitar lista", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "Dirección MAC", "PLAYLIST_EXPORT_SUCCESS": "La lista de reproducción se exportó correctamente.", "PLAYLIST_EXPORT_ERROR": "Error: no se pudo exportar la lista de reproducción.", + "EXPORT_PLAYLIST_FAILED": "No se pudo exportar la lista de reproducción.", "COPY_URL": "Copiar URL al portapapeles", "URL_COPIED": "URL copiada al portapapeles" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Actualizado", "REFRESH": "Actualizar lista", + "REFRESH_FETCHING": "Obteniendo lista de reproducción…", + "REFRESH_READING_FILE": "Leyendo archivo de la lista de reproducción…", + "REFRESH_PARSING": "Analizando lista de reproducción…", + "REFRESH_SAVING": "Guardando lista de reproducción…", "REFRESH_XTREAM": "Actualizar lista Xtream desde el servidor remoto", "REFRESH_XTREAM_DIALOG": { "TITLE": "Actualizar lista Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "todas las listas disponibles", "PLAYLIST_UPDATE_SUCCESS": "¡Éxito! La lista de reproducción se actualizó correctamente.", "PLAYLIST_UPDATE_ERROR": "Error al actualizar los detalles de la lista de reproducción.", + "PLAYLIST_UPDATE_FAILED": "No se pudo actualizar la lista de reproducción.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "No se pudo actualizar la lista. El archivo local ya no está disponible. Verifica la ruta del archivo o vuelve a importar la lista.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "No se pudo actualizar la lista. La aplicación ya no puede acceder al archivo local.", "AUTO_REFRESH_UPDATE_SUCCESS": "¡Listo! Las listas de reproducción se actualizaron correctamente", @@ -166,6 +173,7 @@ "ERROR_403": "Acceso denegado (403): el servidor rechazó la solicitud. La URL puede requerir autenticación o la lista de reproducción está restringida.", "ERROR_404": "Lista de reproducción no encontrada (404): la URL no apunta a una lista válida. Verifica la URL.", "ERROR_401": "No autorizado (401): se requiere autenticación para acceder a esta lista de reproducción.", + "ERROR_413": "Este archivo es demasiado grande. Usa la versión independiente o autoalojada de la aplicación.", "ERROR_INVALID_TLS": "El certificado de este host de la lista de reproducción no es válido.", "ERROR_TLS_HOST_UNKNOWN": "No se pudo determinar el host de la lista de reproducción. Inténtalo de nuevo manualmente.", "TRUST_TLS_HOST": "Confiar en el host", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importar", "EXPORT_DATA": "Exportar", "EXPORTING_DATA": "Exportando…", + "BACKUP_EXPORTED": "Copia de seguridad de las listas de reproducción exportada.", + "BACKUP_EXPORT_FAILED": "No se pudo exportar la copia de seguridad de las listas de reproducción.", "REMOVE_ALL": "Eliminar todas las listas de reproducción y sus datos", "REMOVE_ALL_BUTTON": "Eliminar todo", "REMOVE_ALL_DESCRIPTION": "Elimina todas las listas M3U, Xtream y Stalker guardadas en la aplicación.", @@ -483,6 +493,7 @@ "CONFIRM": "Eliminar todo" }, "IMPORT_ERROR": "Error de importación, intente con otro archivo.", + "BACKUP_IMPORT_SUMMARY": "Importación de la copia de seguridad finalizada. Importadas: {{imported}} · Combinadas: {{merged}} · Omitidas: {{skipped}} · Fallidas: {{failed}}.", "PLAYLISTS_REMOVED": "Se eliminaron todas las listas de reproducción y sus datos.", "MPV_PLAYER_PATH_LABEL": "Ruta de mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Establece la ruta a MPV. En macOS puedes usar el paquete de la app MPV, como /Applications/mpv.app, o la ruta del ejecutable.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Emisora siguiente", "MUTE": "Silenciar", "UNMUTE": "Activar sonido", - "VOLUME": "Volumen" + "VOLUME": "Volumen", + "RADIO": "Radio", + "PAUSED_BADGE": "EN PAUSA" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Ordenar por fecha de adición (más recientes primero)", "SORT_BY_RATING": "Ordenar por calificación", "SORT_BY_NAME": "Ordenar por nombre (A-Z)", + "SORT_CHANNELS": "Ordenar canales", + "SORT_SERVER_ORDER": "Orden del servidor", + "SORT_PLAYLIST_ORDER": "Orden de la lista", "LOAD_MORE": "Cargar más", "REMAINING": "restantes", "LOADING": "Cargando canales…", @@ -1132,6 +1148,7 @@ "DURATION": "Duración", "CAST": "Reparto", "EPISODE_RUN_TIME": "Duración del episodio", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} min/ep.", "RATING": "Clasificación", "IMDB_RATING": "Valoración de IMDb", "KINOPOISK_RATING": "Valoración de Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Buscar categorías…", "NO_RESULTS": "No se encontraron categorías coincidentes", "NO_CATEGORIES": "No hay categorías disponibles", - "SAVE": "Guardar" - } + "SAVE": "Guardar", + "SAVE_FAILED": "No se pudo guardar la visibilidad de las categorías" + }, + "GLOBAL_SEARCH_SUMMARY": "Elementos encontrados: {{count}} · Listas: {{playlists}}" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Reintentar ahora" }, "ALL_CATEGORIES": "Todas las categorías", + "ALL_ITEMS": "Todos los elementos", + "UNNAMED_CATEGORY": "Categoría sin nombre", + "NO_CATEGORIES": "No hay categorías disponibles", + "UNTITLED": "Sin título", "MY_FAVORITES": "Mis favoritos", "ADD_TO_FAVORITES": "Agregar a los favoritos", "REMOVE_FROM_FAVORITES": "Quitar de favoritos", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Activar sonido del tráiler", "PROGRESS_RESET": "Progreso restablecido", "HIDDEN_FROM_CONTINUE_WATCHING": "Oculto de «Continuar viendo»" + }, + "SCROLL_LEFT": "Desplazar a la izquierda", + "SCROLL_RIGHT": "Desplazar a la derecha", + "EPISODE_NAVIGATION": "Navegación de episodios", + "INLINE_PLAYER": "Reproductor", + "REQUEST_ERRORS": { + "XTREAM": "Error en la solicitud de Xtream: {{message}}", + "XTREAM_CONNECTION": "No se pudo conectar con el servidor Xtream", + "STALKER": "Error en la solicitud de Stalker: {{message}} (estado {{status}})", + "NOT_FOUND": "No encontrado" } }, "INFORMATION": "Información", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Abrir en la lista", "RAIL_ALL_CHANNELS": "Todos los canales", "RAIL_GROUPS": "Grupos", "RAIL_CONTEXT_ACTIONS": "Acciones de la sección", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Introduce el PIN para mostrar", "HEADER_LOCK": "Bloquear ahora (control parental)", "HEADER_UNLOCK": "Introducir el PIN parental" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Abriendo en {{player}}…", + "STOP": "Detener {{player}}", + "ERRORS": { + "START_FAILED": "No se pudo iniciar {{player}}. Asegúrate de que está instalado y de que su ruta en Ajustes es correcta.", + "PREVIOUS_CLOSING": "El reproductor anterior aún se está cerrando. Inténtalo de nuevo en un momento.", + "CLOSED_UNEXPECTEDLY": "{{player}} se cerró inesperadamente.", + "STREAM_OPEN_FAILED": "{{player}} no pudo abrir el stream. Puede que la URL no sea válida o que el servidor no responda.", + "UNSUPPORTED_PROTOCOL": "{{player}} no admite el protocolo de este stream. Comprueba la URL del stream.", + "CONNECTION_FAILED": "{{player}} no puede conectarse al servidor del stream. Comprueba tu conexión a internet.", + "ACCESS_DENIED": "Acceso denegado. Puede que el stream requiera credenciales o cabeceras válidas.", + "STREAM_NOT_FOUND": "Stream no encontrado. Puede que la URL sea incorrecta o haya caducado.", + "TIMED_OUT": "Se agotó el tiempo de conexión. El servidor no responde.", + "LAUNCH_FAILED": "No se pudo iniciar {{player}}.", + "UNKNOWN": "Error de {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "El vídeo se está reproduciendo en la ventana del reproductor externo.", + "INSTALL_HINT": "Asegúrate de que MPV o VLC esté instalado correctamente en tu sistema.", + "INSTALL_INSTRUCTIONS": "Instrucciones de instalación", + "DONT_SHOW_AGAIN": "No volver a mostrar" + } } } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 24131a8f1..011c34e53 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Liste Xtream", "STALKER_PORTAL": "Portail Stalker", "CHANNELS": "Chaînes", + "CHANNELS_COUNT": "{{count}} chaînes", "ADDED": "Ajouté", "SHOW_DETAILS": "Afficher les détails de la liste de lecture", "REMOVE": "Retirer la liste de lecture", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "Adresse MAC", "PLAYLIST_EXPORT_SUCCESS": "La liste de lecture a été exportée avec succès.", "PLAYLIST_EXPORT_ERROR": "Erreur : Échec de l'exportation de la liste de lecture.", + "EXPORT_PLAYLIST_FAILED": "Échec de l'exportation de la liste de lecture.", "COPY_URL": "Copier l'URL dans le presse-papiers", "URL_COPIED": "URL copiée dans le presse-papiers" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Mis à jour", "REFRESH": "Mettre à jour la liste de lecture", + "REFRESH_FETCHING": "Récupération de la liste de lecture…", + "REFRESH_READING_FILE": "Lecture du fichier de la liste de lecture…", + "REFRESH_PARSING": "Analyse de la liste de lecture…", + "REFRESH_SAVING": "Enregistrement de la liste de lecture…", "REFRESH_XTREAM": "Actualiser la liste Xtream depuis le serveur", "REFRESH_XTREAM_DIALOG": { "TITLE": "Actualiser la liste Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "toutes les listes disponibles", "PLAYLIST_UPDATE_SUCCESS": "Succès ! La liste de lecture a été mise à jour avec succès.", "PLAYLIST_UPDATE_ERROR": "Erreur lors de la mise à jour des détails de la liste.", + "PLAYLIST_UPDATE_FAILED": "Impossible de mettre à jour la liste de lecture.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Échec de l'actualisation de la liste de lecture. Le fichier local n'est plus disponible. Vérifiez le chemin du fichier ou réimportez la liste de lecture.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Échec de l'actualisation de la liste de lecture. L'application ne peut plus accéder au fichier local.", "AUTO_REFRESH_UPDATE_SUCCESS": "Succès ! Les listes de lecture ont été mises à jour avec succès", @@ -166,6 +173,7 @@ "ERROR_403": "Accès refusé (403) : le serveur a refusé la requête. L'URL peut nécessiter une authentification ou la liste de lecture est restreinte.", "ERROR_404": "Liste de lecture introuvable (404) : l'URL ne pointe pas vers une liste de lecture valide. Veuillez vérifier l'URL.", "ERROR_401": "Non autorisé (401) : une authentification est requise pour accéder à cette liste de lecture.", + "ERROR_413": "Ce fichier est trop volumineux. Utilisez la version autonome ou auto-hébergée de l'application.", "ERROR_INVALID_TLS": "Le certificat de cet hôte de playlist est invalide.", "ERROR_TLS_HOST_UNKNOWN": "Impossible de déterminer l’hôte de la playlist. Veuillez réessayer manuellement.", "TRUST_TLS_HOST": "Faire confiance à l’hôte", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importer", "EXPORT_DATA": "Exporter", "EXPORTING_DATA": "Exportation…", + "BACKUP_EXPORTED": "Sauvegarde des listes de lecture exportée.", + "BACKUP_EXPORT_FAILED": "Échec de l'exportation de la sauvegarde des listes de lecture.", "REMOVE_ALL": "Supprimer toutes les playlists et leurs données", "REMOVE_ALL_BUTTON": "Tout supprimer", "REMOVE_ALL_DESCRIPTION": "Supprime toutes les playlists M3U, Xtream et Stalker stockées dans l’application.", @@ -483,6 +493,7 @@ "CONFIRM": "Tout supprimer" }, "IMPORT_ERROR": "Erreur d'importation, veuillez essayer avec un autre fichier.", + "BACKUP_IMPORT_SUMMARY": "Importation de la sauvegarde terminée : {{imported}} importée(s), {{merged}} fusionnée(s), {{skipped}} ignorée(s), {{failed}} en échec.", "PLAYLISTS_REMOVED": "Toutes les playlists et leurs données ont été supprimées.", "MPV_PLAYER_PATH_LABEL": "Chemin de mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Définissez le chemin vers MPV. Sur macOS, vous pouvez utiliser le bundle de l’app MPV, comme /Applications/mpv.app, ou le chemin de l’exécutable.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Station suivante", "MUTE": "Muet", "UNMUTE": "Activer le son", - "VOLUME": "Volume" + "VOLUME": "Volume", + "RADIO": "Radio", + "PAUSED_BADGE": "EN PAUSE" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Trier par date d'ajout (Plus récent)", "SORT_BY_RATING": "Trier par note", "SORT_BY_NAME": "Trier par nom (A-Z)", + "SORT_CHANNELS": "Trier les chaînes", + "SORT_SERVER_ORDER": "Ordre du serveur", + "SORT_PLAYLIST_ORDER": "Ordre de la liste de lecture", "LOAD_MORE": "Charger plus", "REMAINING": "restantes", "LOADING": "Chargement des chaînes…", @@ -1132,6 +1148,7 @@ "DURATION": "Durée", "CAST": "Distribution", "EPISODE_RUN_TIME": "Durée de l’épisode", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} min/ép.", "RATING": "Note", "IMDB_RATING": "Note IMDb", "KINOPOISK_RATING": "Note Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Rechercher des catégories…", "NO_RESULTS": "Aucune catégorie correspondante trouvée", "NO_CATEGORIES": "Aucune catégorie disponible", - "SAVE": "Enregistrer" - } + "SAVE": "Enregistrer", + "SAVE_FAILED": "Échec de l'enregistrement de la visibilité des catégories" + }, + "GLOBAL_SEARCH_SUMMARY": "{{count}} élément(s) trouvé(s) dans {{playlists}} liste(s) de lecture" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Réessayer maintenant" }, "ALL_CATEGORIES": "Toutes les catégories", + "ALL_ITEMS": "Tous les éléments", + "UNNAMED_CATEGORY": "Catégorie sans nom", + "NO_CATEGORIES": "Aucune catégorie disponible", + "UNTITLED": "Sans titre", "MY_FAVORITES": "Mes favoris", "ADD_TO_FAVORITES": "Ajouter aux favoris", "REMOVE_FROM_FAVORITES": "Retirer des favoris", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Activer le son de la bande-annonce", "PROGRESS_RESET": "Progression réinitialisée", "HIDDEN_FROM_CONTINUE_WATCHING": "Masqué de « Reprendre »" + }, + "SCROLL_LEFT": "Faire défiler vers la gauche", + "SCROLL_RIGHT": "Faire défiler vers la droite", + "EPISODE_NAVIGATION": "Navigation entre les épisodes", + "INLINE_PLAYER": "Lecteur", + "REQUEST_ERRORS": { + "XTREAM": "Échec de la requête Xtream : {{message}}", + "XTREAM_CONNECTION": "Échec de la connexion au serveur Xtream", + "STALKER": "Échec de la requête Stalker : {{message}} (statut {{status}})", + "NOT_FOUND": "Introuvable" } }, "INFORMATION": "Information", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Ouvrir dans la liste de lecture", "RAIL_ALL_CHANNELS": "Toutes les chaînes", "RAIL_GROUPS": "Groupes", "RAIL_CONTEXT_ACTIONS": "Actions de la section", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Saisir le code PIN pour afficher", "HEADER_LOCK": "Verrouiller maintenant (contrôle parental)", "HEADER_UNLOCK": "Saisir le code PIN parental" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Ouverture dans {{player}}…", + "STOP": "Arrêter {{player}}", + "ERRORS": { + "START_FAILED": "Impossible de démarrer {{player}}. Vérifiez qu'il est installé et que son chemin dans les paramètres est correct.", + "PREVIOUS_CLOSING": "Le lecteur précédent est encore en cours de fermeture. Réessayez dans un instant.", + "CLOSED_UNEXPECTEDLY": "{{player}} s'est fermé de manière inattendue.", + "STREAM_OPEN_FAILED": "{{player}} n'a pas pu ouvrir le flux. L'URL est peut-être invalide ou le serveur ne répond pas.", + "UNSUPPORTED_PROTOCOL": "{{player}} ne prend pas en charge le protocole de ce flux. Vérifiez l'URL du flux.", + "CONNECTION_FAILED": "{{player}} ne parvient pas à se connecter au serveur du flux. Vérifiez votre connexion Internet.", + "ACCESS_DENIED": "Accès refusé. Le flux nécessite peut-être des identifiants ou des en-têtes valides.", + "STREAM_NOT_FOUND": "Flux introuvable. L'URL est peut-être incorrecte ou expirée.", + "TIMED_OUT": "Délai de connexion dépassé. Le serveur ne répond pas.", + "LAUNCH_FAILED": "Impossible de démarrer {{player}}.", + "UNKNOWN": "Erreur {{player}} : {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "La vidéo est lue dans la fenêtre du lecteur externe.", + "INSTALL_HINT": "Vérifiez que MPV ou VLC est correctement installé sur votre système.", + "INSTALL_INSTRUCTIONS": "Instructions d'installation", + "DONT_SHOW_AGAIN": "Ne plus afficher" + } } } diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index fb201a877..d0795f377 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream-lejátszási lista", "STALKER_PORTAL": "Stalker-portál", "CHANNELS": "Csatornák", + "CHANNELS_COUNT": "{{count}} csatorna", "ADDED": "Hozzáadva", "SHOW_DETAILS": "Lejátszási lista részleteinek megjelenítése", "REMOVE": "Lejátszási lista eltávolítása", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC-cím", "PLAYLIST_EXPORT_SUCCESS": "A lejátszási lista exportálása sikerült.", "PLAYLIST_EXPORT_ERROR": "Hiba: nem sikerült exportálni a lejátszási listát.", + "EXPORT_PLAYLIST_FAILED": "Nem sikerült exportálni a lejátszási listát.", "COPY_URL": "URL-cím másolása a vágólapra", "URL_COPIED": "Az URL-cím a vágólapra került" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Frissítve", "REFRESH": "Lejátszási lista frissítése", + "REFRESH_FETCHING": "Lejátszási lista lekérése…", + "REFRESH_READING_FILE": "Lejátszási lista fájljának beolvasása…", + "REFRESH_PARSING": "Lejátszási lista feldolgozása…", + "REFRESH_SAVING": "Lejátszási lista mentése…", "REFRESH_XTREAM": "Xtream-lejátszási lista frissítése a távoli forrásból", "REFRESH_XTREAM_DIALOG": { "TITLE": "Xtream-lejátszási lista frissítése", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "Az összes elérhető lejátszási lista", "PLAYLIST_UPDATE_SUCCESS": "Sikeresen frissült a lejátszási lista.", "PLAYLIST_UPDATE_ERROR": "Hiba történt a lejátszási lista adatainak frissítésekor.", + "PLAYLIST_UPDATE_FAILED": "Nem sikerült frissíteni a lejátszási listát.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Nem sikerült frissíteni a lejátszási listát, mert a helyi fájl már nem érhető el. Ellenőrizze a fájl elérési útját, vagy importálja újra a listát.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Nem sikerült frissíteni a lejátszási listát, mert az alkalmazás már nem fér hozzá a helyi fájlhoz.", "AUTO_REFRESH_UPDATE_SUCCESS": "Sikeresen frissültek a lejátszási listák.", @@ -166,6 +173,7 @@ "ERROR_403": "Hozzáférés megtagadva (403): a kiszolgáló elutasította a kérést. Elképzelhető, hogy az URL-cím hitelesítést igényel, vagy a lejátszási lista korlátozott hozzáférésű.", "ERROR_404": "A lejátszási lista nem található (404): az URL-cím nem érvényes lejátszási listára mutat. Ellenőrizze a címet.", "ERROR_401": "Nincs jogosultság (401): a lejátszási lista eléréséhez hitelesítés szükséges.", + "ERROR_413": "Ez a fájl túl nagy. Használja az alkalmazás asztali vagy saját kiszolgálón futtatott változatát.", "ERROR_INVALID_TLS": "A lejátszási lista kiszolgálójának tanúsítványa érvénytelen.", "ERROR_TLS_HOST_UNKNOWN": "Nem sikerült meghatározni a lejátszási lista kiszolgálóját. Próbálja újra kézzel.", "TRUST_TLS_HOST": "Kiszolgáló megbízhatónak jelölése", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importálás", "EXPORT_DATA": "Exportálás", "EXPORTING_DATA": "Exportálás…", + "BACKUP_EXPORTED": "A lejátszási listák biztonsági mentésének exportálása sikerült.", + "BACKUP_EXPORT_FAILED": "Nem sikerült exportálni a lejátszási listák biztonsági mentését.", "REMOVE_ALL": "Az összes lejátszási lista és kapcsolódó adat törlése", "REMOVE_ALL_BUTTON": "Összes törlése", "REMOVE_ALL_DESCRIPTION": "Az alkalmazásban tárolt összes M3U-, Xtream- és Stalker-lejátszási lista törlése.", @@ -483,6 +493,7 @@ "CONFIRM": "Minden törlése" }, "IMPORT_ERROR": "Importálási hiba. Próbálkozzon másik fájllal.", + "BACKUP_IMPORT_SUMMARY": "A biztonsági mentés importálása befejeződött: {{imported}} importálva, {{merged}} egyesítve, {{skipped}} kihagyva, {{failed}} sikertelen.", "PLAYLISTS_REMOVED": "Eltávolítottuk az összes lejátszási listát és kapcsolódó adatot.", "MPV_PLAYER_PATH_LABEL": "MPV elérési útvonala", "MPV_PLAYER_PATH_DESCRIPTION": "Adja meg az MPV elérési útját. macOS rendszeren használható az MPV alkalmazáscsomag, például /Applications/mpv.app, vagy a futtatható fájl elérési útja.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Következő állomás", "MUTE": "Némítás", "UNMUTE": "Némítás feloldása", - "VOLUME": "Hangerő" + "VOLUME": "Hangerő", + "RADIO": "Rádió", + "PAUSED_BADGE": "SZÜNET" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Hozzáadás dátuma szerint (legújabb elöl)", "SORT_BY_RATING": "Értékelés szerint", "SORT_BY_NAME": "Név szerint (A–Z)", + "SORT_CHANNELS": "Csatornák rendezése", + "SORT_SERVER_ORDER": "Kiszolgáló szerinti sorrend", + "SORT_PLAYLIST_ORDER": "Lejátszási lista szerinti sorrend", "LOAD_MORE": "Továbbiak betöltése", "REMAINING": "van hátra", "LOADING": "Csatornák betöltése…", @@ -1132,6 +1148,7 @@ "DURATION": "Időtartam", "CAST": "Szereplők", "EPISODE_RUN_TIME": "Epizód hossza", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} perc/epizód", "RATING": "Értékelés", "IMDB_RATING": "IMDb-értékelés", "KINOPOISK_RATING": "Kinopoisk-értékelés", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Keresés a kategóriák között…", "NO_RESULTS": "Nem található a keresésnek megfelelő kategória", "NO_CATEGORIES": "Nincs elérhető kategória", - "SAVE": "Mentés" - } + "SAVE": "Mentés", + "SAVE_FAILED": "Nem sikerült menteni a kategóriák láthatóságát" + }, + "GLOBAL_SEARCH_SUMMARY": "{{count}} találat {{playlists}} lejátszási listában" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Újrapróbálkozás most" }, "ALL_CATEGORIES": "Összes kategória", + "ALL_ITEMS": "Összes elem", + "UNNAMED_CATEGORY": "Névtelen kategória", + "NO_CATEGORIES": "Nincs elérhető kategória", + "UNTITLED": "Névtelen", "MY_FAVORITES": "Saját kedvencek", "ADD_TO_FAVORITES": "Hozzáadás a kedvencekhez", "REMOVE_FROM_FAVORITES": "Eltávolítás a kedvencek közül", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Előzetes hangjának bekapcsolása", "PROGRESS_RESET": "Haladás törölve", "HIDDEN_FROM_CONTINUE_WATCHING": "Elrejtve a „Folytatás” sorból" + }, + "SCROLL_LEFT": "Görgetés balra", + "SCROLL_RIGHT": "Görgetés jobbra", + "EPISODE_NAVIGATION": "Epizódnavigáció", + "INLINE_PLAYER": "Lejátszó", + "REQUEST_ERRORS": { + "XTREAM": "Sikertelen Xtream-kérés: {{message}}", + "XTREAM_CONNECTION": "Nem sikerült kapcsolódni az Xtream-kiszolgálóhoz", + "STALKER": "Sikertelen Stalker-kérés: {{message}} (állapotkód: {{status}})", + "NOT_FOUND": "Nem található" } }, "INFORMATION": "Információk", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Megnyitás a lejátszási listában", "RAIL_ALL_CHANNELS": "Összes csatorna", "RAIL_GROUPS": "Csoportok", "RAIL_CONTEXT_ACTIONS": "Szakaszműveletek", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "A megjelenítéshez adja meg a PIN-kódot", "HEADER_LOCK": "Zárolás most (szülői felügyelet)", "HEADER_UNLOCK": "Szülői PIN-kód megadása" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "{{player}} megnyitása…", + "STOP": "{{player}} leállítása", + "ERRORS": { + "START_FAILED": "A(z) {{player}} nem indítható el. Ellenőrizze, hogy telepítve van-e, és hogy helyes-e az elérési útja a Beállításokban.", + "PREVIOUS_CLOSING": "Az előző lejátszó bezárása még folyamatban van. Próbálja újra egy kis idő múlva.", + "CLOSED_UNEXPECTEDLY": "A(z) {{player}} váratlanul bezárult.", + "STREAM_OPEN_FAILED": "A(z) {{player}} nem tudta megnyitni az adatfolyamot. Lehet, hogy az URL-cím érvénytelen, vagy a kiszolgáló nem válaszol.", + "UNSUPPORTED_PROTOCOL": "A(z) {{player}} nem támogatja ennek az adatfolyamnak a protokollját. Ellenőrizze az adatfolyam URL-címét.", + "CONNECTION_FAILED": "A(z) {{player}} nem tud kapcsolódni az adatfolyam kiszolgálójához. Ellenőrizze az internetkapcsolatot.", + "ACCESS_DENIED": "Hozzáférés megtagadva. Az adatfolyamhoz érvényes hitelesítési adatok vagy fejlécek szükségesek lehetnek.", + "STREAM_NOT_FOUND": "Az adatfolyam nem található. Lehet, hogy az URL-cím hibás, vagy lejárt.", + "TIMED_OUT": "Időtúllépés a kapcsolódáskor. A kiszolgáló nem válaszol.", + "LAUNCH_FAILED": "A(z) {{player}} nem indítható el.", + "UNKNOWN": "{{player}}-hiba: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "A videó lejátszása a külső lejátszó ablakában történik.", + "INSTALL_HINT": "Ellenőrizze, hogy az MPV vagy a VLC megfelelően telepítve van-e a rendszerén.", + "INSTALL_INSTRUCTIONS": "Telepítési útmutató", + "DONT_SHOW_AGAIN": "Ne jelenjen meg újra" + } } } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index f3f7db425..7a79742a3 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Playlist Xtream", "STALKER_PORTAL": "Portale Stalker", "CHANNELS": "Canali", + "CHANNELS_COUNT": "Canali: {{count}}", "ADDED": "Aggiunto", "SHOW_DETAILS": "Visualizza i dettagli della playlist", "REMOVE": "Rimuovi playlist", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "Indirizzo MAC", "PLAYLIST_EXPORT_SUCCESS": "Playlist esportata correttamente.", "PLAYLIST_EXPORT_ERROR": "Errore: esportazione della playlist fallita.", + "EXPORT_PLAYLIST_FAILED": "Esportazione della playlist non riuscita.", "COPY_URL": "Copia URL negli appunti", "URL_COPIED": "URL copiato negli appunti" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Aggiornata", "REFRESH": "Aggiorna playlist", + "REFRESH_FETCHING": "Recupero della playlist…", + "REFRESH_READING_FILE": "Lettura del file della playlist…", + "REFRESH_PARSING": "Analisi della playlist…", + "REFRESH_SAVING": "Salvataggio della playlist…", "REFRESH_XTREAM": "Aggiorna playlist Xtream dal server remoto", "REFRESH_XTREAM_DIALOG": { "TITLE": "Aggiorna playlist Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "tutte le playlist disponibili", "PLAYLIST_UPDATE_SUCCESS": "La playlist è stata correttamente aggiornata.", "PLAYLIST_UPDATE_ERROR": "Errore durante l'aggiornamento dei dettagli della playlist.", + "PLAYLIST_UPDATE_FAILED": "Impossibile aggiornare la playlist.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Aggiornamento della playlist non riuscito. Il file locale non è più disponibile. Controlla il percorso del file o reimporta la playlist.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Aggiornamento della playlist non riuscito. L'app non può più accedere al file locale.", "AUTO_REFRESH_UPDATE_SUCCESS": "Le playlist sono state aggiornate correttamente", @@ -166,6 +173,7 @@ "ERROR_403": "Accesso negato (403): il server ha rifiutato la richiesta. L'URL potrebbe richiedere autenticazione o la playlist è soggetta a restrizioni.", "ERROR_404": "Playlist non trovata (404): l'URL non punta a una playlist valida. Controlla l'URL.", "ERROR_401": "Non autorizzato (401): è richiesta l'autenticazione per accedere a questa playlist.", + "ERROR_413": "Il file è troppo grande. Usa la versione standalone o self-hosted dell'app.", "ERROR_INVALID_TLS": "Il certificato di questo host della playlist non è valido.", "ERROR_TLS_HOST_UNKNOWN": "Impossibile determinare l’host della playlist. Riprova manualmente.", "TRUST_TLS_HOST": "Considera attendibile l’host", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importa", "EXPORT_DATA": "Esporta", "EXPORTING_DATA": "Esportazione...", + "BACKUP_EXPORTED": "Copia di sicurezza delle playlist esportata.", + "BACKUP_EXPORT_FAILED": "Esportazione della copia di sicurezza delle playlist non riuscita.", "REMOVE_ALL": "Elimina tutte le playlist e i dati delle playlist", "REMOVE_ALL_BUTTON": "Elimina tutto", "REMOVE_ALL_DESCRIPTION": "Elimina ogni playlist M3U, Xtream e Stalker salvata nell’app.", @@ -483,6 +493,7 @@ "CONFIRM": "Elimina tutto" }, "IMPORT_ERROR": "Errore di importazione, prova con un altro file.", + "BACKUP_IMPORT_SUMMARY": "Importazione della copia di sicurezza completata: importate {{imported}}, unite {{merged}}, ignorate {{skipped}}, non riuscite {{failed}}.", "PLAYLISTS_REMOVED": "Tutte le playlist e i dati delle playlist sono stati rimossi.", "MPV_PLAYER_PATH_LABEL": "Percorso mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Imposta il percorso di MPV. Su macOS puoi usare il bundle dell’app MPV, come /Applications/mpv.app, oppure il percorso dell’eseguibile.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Stazione successiva", "MUTE": "Disattiva audio", "UNMUTE": "Attiva audio", - "VOLUME": "Volume" + "VOLUME": "Volume", + "RADIO": "Radio", + "PAUSED_BADGE": "IN PAUSA" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Data inserimento (prima più recenti)", "SORT_BY_RATING": "Valutazione", "SORT_BY_NAME": "Nome (A-Z)", + "SORT_CHANNELS": "Ordina canali", + "SORT_SERVER_ORDER": "Ordine del server", + "SORT_PLAYLIST_ORDER": "Ordine della playlist", "LOAD_MORE": "Carica altri", "REMAINING": "rimanenti", "LOADING": "Caricamento canali...", @@ -1132,6 +1148,7 @@ "DURATION": "Durata", "CAST": "Interpreti", "EPISODE_RUN_TIME": "Durata episodio", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} min/episodio", "RATING": "Classifica", "IMDB_RATING": "Valutazione IMDb", "KINOPOISK_RATING": "Valutazione Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Cerca categorie...", "NO_RESULTS": "Nessuna categoria corrispondente", "NO_CATEGORIES": "Nessuna categoria disponibile", - "SAVE": "Salva" - } + "SAVE": "Salva", + "SAVE_FAILED": "Impossibile salvare la visibilità delle categorie" + }, + "GLOBAL_SEARCH_SUMMARY": "Elementi trovati: {{count}} in {{playlists}} playlist" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Riprova ora" }, "ALL_CATEGORIES": "Tutte le categorie", + "ALL_ITEMS": "Tutti gli elementi", + "UNNAMED_CATEGORY": "Categoria senza nome", + "NO_CATEGORIES": "Nessuna categoria disponibile", + "UNTITLED": "Senza titolo", "MY_FAVORITES": "I miei preferiti", "ADD_TO_FAVORITES": "Aggiungi ai preferiti", "REMOVE_FROM_FAVORITES": "Rimuovi dai preferiti", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Attiva audio del trailer", "PROGRESS_RESET": "Avanzamento azzerato", "HIDDEN_FROM_CONTINUE_WATCHING": "Nascosto da «Continua a guardare»" + }, + "SCROLL_LEFT": "Scorri a sinistra", + "SCROLL_RIGHT": "Scorri a destra", + "EPISODE_NAVIGATION": "Navigazione episodi", + "INLINE_PLAYER": "Lettore", + "REQUEST_ERRORS": { + "XTREAM": "Richiesta Xtream non riuscita: {{message}}", + "XTREAM_CONNECTION": "Impossibile connettersi al server Xtream", + "STALKER": "Richiesta Stalker non riuscita: {{message}} (stato {{status}})", + "NOT_FOUND": "Non trovato" } }, "INFORMATION": "Informazioni", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Apri nella playlist", "RAIL_ALL_CHANNELS": "Tutti i canali", "RAIL_GROUPS": "Gruppi", "RAIL_CONTEXT_ACTIONS": "Azioni sezione", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Inserisci il PIN per mostrare", "HEADER_LOCK": "Blocca ora (controllo parentale)", "HEADER_UNLOCK": "Inserisci il PIN parentale" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Apertura in {{player}}…", + "STOP": "Arresta {{player}}", + "ERRORS": { + "START_FAILED": "Impossibile avviare {{player}}. Assicurati che sia installato e che il suo percorso nelle Impostazioni sia corretto.", + "PREVIOUS_CLOSING": "Il lettore precedente è ancora in chiusura. Riprova tra un momento.", + "CLOSED_UNEXPECTEDLY": "{{player}} si è chiuso inaspettatamente.", + "STREAM_OPEN_FAILED": "{{player}} non è riuscito ad aprire lo stream. L'URL potrebbe non essere valido o il server non risponde.", + "UNSUPPORTED_PROTOCOL": "{{player}} non supporta il protocollo di questo stream. Controlla l'URL dello stream.", + "CONNECTION_FAILED": "{{player}} non riesce a connettersi al server dello stream. Controlla la connessione a internet.", + "ACCESS_DENIED": "Accesso negato. Lo stream potrebbe richiedere credenziali o intestazioni valide.", + "STREAM_NOT_FOUND": "Stream non trovato. L'URL potrebbe essere errato o scaduto.", + "TIMED_OUT": "Tempo di connessione scaduto. Il server non risponde.", + "LAUNCH_FAILED": "Impossibile avviare {{player}}.", + "UNKNOWN": "Errore di {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "Il video è in riproduzione nella finestra del lettore esterno.", + "INSTALL_HINT": "Assicurati che MPV o VLC sia installato correttamente sul tuo sistema.", + "INSTALL_INSTRUCTIONS": "Istruzioni di installazione", + "DONT_SHOW_AGAIN": "Non mostrare più" + } } } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index dd739f4c1..36a7ef5d2 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtremeプレイリスト", "STALKER_PORTAL": "Stalkerポータル", "CHANNELS": "チャンネル", + "CHANNELS_COUNT": "{{count}}チャンネル", "ADDED": "追加済み", "SHOW_DETAILS": "プレイリストの詳細を表示", "REMOVE": "プレイリストを削除", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MACアドレス", "PLAYLIST_EXPORT_SUCCESS": "プレイリストを正常にエクスポートしました。", "PLAYLIST_EXPORT_ERROR": "エラー:プレイリストのエクスポートに失敗しました。", + "EXPORT_PLAYLIST_FAILED": "プレイリストのエクスポートに失敗しました。", "COPY_URL": "URLをクリップボードにコピー", "URL_COPIED": "URLをクリップボードにコピーしました" }, @@ -107,6 +109,10 @@ }, "UPDATED": "更新済み", "REFRESH": "プレイリストを更新", + "REFRESH_FETCHING": "プレイリストを取得中…", + "REFRESH_READING_FILE": "プレイリストファイルを読み込み中…", + "REFRESH_PARSING": "プレイリストを解析中…", + "REFRESH_SAVING": "プレイリストを保存中…", "REFRESH_XTREAM": "リモートからXtreamプレイリストを更新", "REFRESH_XTREAM_DIALOG": { "TITLE": "Xtreamプレイリストを更新", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "利用可能な全プレイリスト", "PLAYLIST_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました。", "PLAYLIST_UPDATE_ERROR": "プレイリストの詳細を更新できませんでした。", + "PLAYLIST_UPDATE_FAILED": "プレイリストを更新できませんでした。", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "プレイリストの更新に失敗しました。ローカルファイルが利用できなくなっています。ファイルパスを確認するか、プレイリストを再インポートしてください。", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "プレイリストの更新に失敗しました。アプリがローカルファイルにアクセスできなくなりました。", "AUTO_REFRESH_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました", @@ -166,6 +173,7 @@ "ERROR_403": "アクセスが拒否されました(403):サーバーがリクエストを拒否しました。URLに認証が必要か、プレイリストが制限されている可能性があります。", "ERROR_404": "プレイリストが見つかりません(404):URLは有効なプレイリストを指していません。URLを確認してください。", "ERROR_401": "未認証(401):このプレイリストにアクセスするには認証が必要です。", + "ERROR_413": "ファイルが大きすぎます。スタンドアロン版またはセルフホスト版のアプリをご利用ください。", "ERROR_INVALID_TLS": "このプレイリストホストの証明書は無効です。", "ERROR_TLS_HOST_UNKNOWN": "プレイリストのホストを特定できませんでした。手動で再試行してください。", "TRUST_TLS_HOST": "ホストを信頼", @@ -466,6 +474,8 @@ "IMPORT_DATA": "インポート", "EXPORT_DATA": "エクスポート", "EXPORTING_DATA": "エクスポート中…", + "BACKUP_EXPORTED": "プレイリストバックアップをエクスポートしました。", + "BACKUP_EXPORT_FAILED": "プレイリストバックアップのエクスポートに失敗しました。", "REMOVE_ALL": "すべてのプレイリストとプレイリストデータを削除", "REMOVE_ALL_BUTTON": "すべて削除", "REMOVE_ALL_DESCRIPTION": "アプリに保存されているすべての M3U、Xtream、Stalker プレイリストを削除します。", @@ -483,6 +493,7 @@ "CONFIRM": "すべて削除" }, "IMPORT_ERROR": "インポートエラー。別のファイルで試してください。", + "BACKUP_IMPORT_SUMMARY": "バックアップのインポートが完了しました:インポート {{imported}} 件、統合 {{merged}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件。", "PLAYLISTS_REMOVED": "すべてのプレイリストとプレイリストデータを削除しました。", "MPV_PLAYER_PATH_LABEL": "mpv のパス", "MPV_PLAYER_PATH_DESCRIPTION": "MPV へのパスを設定します。macOS では /Applications/mpv.app のような MPV アプリバンドル、または実行ファイルのパスを使用できます。", @@ -623,7 +634,9 @@ "NEXT_STATION": "次の局", "MUTE": "ミュート", "UNMUTE": "ミュート解除", - "VOLUME": "音量" + "VOLUME": "音量", + "RADIO": "ラジオ", + "PAUSED_BADGE": "一時停止中" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "追加日で並び替え(新しい順)", "SORT_BY_RATING": "評価で並び替え", "SORT_BY_NAME": "名前で並び替え(A-Z)", + "SORT_CHANNELS": "チャンネルを並び替え", + "SORT_SERVER_ORDER": "サーバー順", + "SORT_PLAYLIST_ORDER": "プレイリスト順", "LOAD_MORE": "さらに読み込む", "REMAINING": "残り", "LOADING": "チャンネルを読み込み中...", @@ -1132,6 +1148,7 @@ "DURATION": "再生時間", "CAST": "出演者", "EPISODE_RUN_TIME": "エピソードの再生時間", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}}分/話", "RATING": "評価", "IMDB_RATING": "IMDb 評価", "KINOPOISK_RATING": "Kinopoisk 評価", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "カテゴリーを検索...", "NO_RESULTS": "一致するカテゴリーが見つかりません", "NO_CATEGORIES": "利用可能なカテゴリーはありません", - "SAVE": "保存" - } + "SAVE": "保存", + "SAVE_FAILED": "カテゴリーの表示設定を保存できませんでした" + }, + "GLOBAL_SEARCH_SUMMARY": "{{playlists}}件のプレイリストで{{count}}件の項目が見つかりました" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "今すぐ再試行" }, "ALL_CATEGORIES": "すべてのカテゴリー", + "ALL_ITEMS": "すべての項目", + "UNNAMED_CATEGORY": "名前のないカテゴリー", + "NO_CATEGORIES": "利用可能なカテゴリーはありません", + "UNTITLED": "無題", "MY_FAVORITES": "お気に入り", "ADD_TO_FAVORITES": "お気に入りに追加", "REMOVE_FROM_FAVORITES": "お気に入りから削除", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "予告編の音を出す", "PROGRESS_RESET": "再生位置をリセットしました", "HIDDEN_FROM_CONTINUE_WATCHING": "「続きを見る」から隠しました" + }, + "SCROLL_LEFT": "左にスクロール", + "SCROLL_RIGHT": "右にスクロール", + "EPISODE_NAVIGATION": "エピソードナビゲーション", + "INLINE_PLAYER": "プレーヤー", + "REQUEST_ERRORS": { + "XTREAM": "Xtream リクエストに失敗しました:{{message}}", + "XTREAM_CONNECTION": "Xtream サーバーに接続できませんでした", + "STALKER": "Stalker リクエストに失敗しました:{{message}}(ステータス {{status}})", + "NOT_FOUND": "見つかりません" } }, "INFORMATION": "情報", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "プレイリストで開く", "RAIL_ALL_CHANNELS": "すべてのチャンネル", "RAIL_GROUPS": "グループ", "RAIL_CONTEXT_ACTIONS": "コンテキスト操作", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "PIN を入力して表示", "HEADER_LOCK": "今すぐロック(ペアレンタルロック)", "HEADER_UNLOCK": "ペアレンタル PIN を入力" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "{{player}} で開いています…", + "STOP": "{{player}} を停止", + "ERRORS": { + "START_FAILED": "{{player}} を起動できませんでした。インストールされていること、および設定のパスが正しいことを確認してください。", + "PREVIOUS_CLOSING": "前のプレーヤーがまだ終了処理中です。しばらくしてからもう一度お試しください。", + "CLOSED_UNEXPECTEDLY": "{{player}} が予期せず終了しました。", + "STREAM_OPEN_FAILED": "{{player}} でストリームを開けませんでした。URL が無効か、サーバーが応答していない可能性があります。", + "UNSUPPORTED_PROTOCOL": "{{player}} はこのストリームのプロトコルに対応していません。ストリーム URL を確認してください。", + "CONNECTION_FAILED": "{{player}} がストリームサーバーに接続できません。インターネット接続を確認してください。", + "ACCESS_DENIED": "アクセスが拒否されました。このストリームには有効な認証情報またはヘッダーが必要な可能性があります。", + "STREAM_NOT_FOUND": "ストリームが見つかりません。URL が正しくないか、期限切れの可能性があります。", + "TIMED_OUT": "接続がタイムアウトしました。サーバーが応答していません。", + "LAUNCH_FAILED": "{{player}} を起動できませんでした。", + "UNKNOWN": "{{player}} のエラー:{{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "動画は外部プレーヤーのウィンドウで再生中です。", + "INSTALL_HINT": "MPV または VLC がシステムに正しくインストールされていることを確認してください。", + "INSTALL_INSTRUCTIONS": "インストール手順", + "DONT_SHOW_AGAIN": "今後表示しない" + } } } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 6fcbb517e..e407e3796 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream 재생목록", "STALKER_PORTAL": "Stalker 포털", "CHANNELS": "채널", + "CHANNELS_COUNT": "{{count}}개 채널", "ADDED": "추가되었습니다", "SHOW_DETAILS": "재생 목록 세부 정보 표시", "REMOVE": "재생 목록 제거", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC 주소", "PLAYLIST_EXPORT_SUCCESS": "재생목록을 성공적으로 내보냈습니다.", "PLAYLIST_EXPORT_ERROR": "오류: 재생목록 내보내기에 실패했습니다.", + "EXPORT_PLAYLIST_FAILED": "재생목록 내보내기에 실패했습니다.", "COPY_URL": "URL을 클립보드에 복사", "URL_COPIED": "URL이 클립보드에 복사되었습니다" }, @@ -107,6 +109,10 @@ }, "UPDATED": "업데이트 됨", "REFRESH": "재생 목록 새로 고침", + "REFRESH_FETCHING": "재생목록을 가져오는 중…", + "REFRESH_READING_FILE": "재생목록 파일을 읽는 중…", + "REFRESH_PARSING": "재생목록을 파싱하는 중…", + "REFRESH_SAVING": "재생목록을 저장하는 중…", "REFRESH_XTREAM": "원격 서버에서 Xtream 재생목록 새로 고침", "REFRESH_XTREAM_DIALOG": { "TITLE": "Xtream 재생목록 새로 고침", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "사용 가능한 모든 재생목록", "PLAYLIST_UPDATE_SUCCESS": "성공! 재생목록이 성공적으로 업데이트되었습니다.", "PLAYLIST_UPDATE_ERROR": "재생목록 정보 업데이트 중 오류가 발생했습니다.", + "PLAYLIST_UPDATE_FAILED": "재생목록을 업데이트하지 못했습니다.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "재생목록 새로 고침에 실패했습니다. 로컬 파일을 더 이상 사용할 수 없습니다. 파일 경로를 확인하거나 재생목록을 다시 가져오세요.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "재생목록 새로 고침에 실패했습니다. 앱이 더 이상 로컬 파일에 접근할 수 없습니다.", "AUTO_REFRESH_UPDATE_SUCCESS": "성공! 재생목록이 성공적으로 업데이트되었습니다", @@ -166,6 +173,7 @@ "ERROR_403": "접근 거부됨 (403): 서버가 요청을 거부했습니다. URL에 인증이 필요하거나 재생목록이 제한되어 있을 수 있습니다.", "ERROR_404": "재생목록을 찾을 수 없습니다 (404): URL이 유효한 재생목록을 가리키지 않습니다. URL을 확인해 주세요.", "ERROR_401": "권한 없음 (401): 이 재생목록에 접근하려면 인증이 필요합니다.", + "ERROR_413": "파일이 너무 큽니다. 독립 실행형 또는 자체 호스팅 버전의 앱을 사용하세요.", "ERROR_INVALID_TLS": "이 플레이리스트 호스트의 인증서가 유효하지 않습니다.", "ERROR_TLS_HOST_UNKNOWN": "플레이리스트 호스트를 확인할 수 없습니다. 수동으로 다시 시도해 주세요.", "TRUST_TLS_HOST": "호스트 신뢰", @@ -466,6 +474,8 @@ "IMPORT_DATA": "가져오기", "EXPORT_DATA": "내보내기", "EXPORTING_DATA": "내보내는 중…", + "BACKUP_EXPORTED": "플레이리스트 백업을 내보냈습니다.", + "BACKUP_EXPORT_FAILED": "플레이리스트 백업을 내보내지 못했습니다.", "REMOVE_ALL": "모든 플레이리스트와 플레이리스트 데이터 삭제", "REMOVE_ALL_BUTTON": "모두 삭제", "REMOVE_ALL_DESCRIPTION": "앱에 저장된 모든 M3U, Xtream, Stalker 플레이리스트를 삭제합니다.", @@ -483,6 +493,7 @@ "CONFIRM": "모두 삭제" }, "IMPORT_ERROR": "가져오기 오류가 발생했습니다. 다른 파일로 시도해 보세요.", + "BACKUP_IMPORT_SUMMARY": "백업 가져오기 완료: {{imported}}개 가져옴, {{merged}}개 병합, {{skipped}}개 건너뜀, {{failed}}개 실패.", "PLAYLISTS_REMOVED": "모든 플레이리스트와 플레이리스트 데이터가 삭제되었습니다.", "MPV_PLAYER_PATH_LABEL": "mpv 경로", "MPV_PLAYER_PATH_DESCRIPTION": "MPV 경로를 설정합니다. macOS에서는 /Applications/mpv.app 같은 MPV 앱 번들이나 실행 파일 경로를 사용할 수 있습니다.", @@ -623,7 +634,9 @@ "NEXT_STATION": "다음 방송국", "MUTE": "음소거", "UNMUTE": "음소거 해제", - "VOLUME": "볼륨" + "VOLUME": "볼륨", + "RADIO": "라디오", + "PAUSED_BADGE": "일시정지" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "추가 날짜순 정렬 (최신순)", "SORT_BY_RATING": "평점순 정렬", "SORT_BY_NAME": "이름순 정렬 (A-Z)", + "SORT_CHANNELS": "채널 정렬", + "SORT_SERVER_ORDER": "서버 순서", + "SORT_PLAYLIST_ORDER": "재생목록 순서", "LOAD_MORE": "더 불러오기", "REMAINING": "남음", "LOADING": "채널을 불러오는 중...", @@ -1132,6 +1148,7 @@ "DURATION": "재생 시간", "CAST": "출연진", "EPISODE_RUN_TIME": "에피소드 재생 시간", + "EPISODE_RUN_TIME_MINUTES": "편당 {{minutes}}분", "RATING": "평가", "IMDB_RATING": "IMDb 평점", "KINOPOISK_RATING": "Kinopoisk 평점", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "카테고리 검색...", "NO_RESULTS": "일치하는 카테고리를 찾을 수 없습니다", "NO_CATEGORIES": "사용 가능한 카테고리가 없습니다", - "SAVE": "저장" - } + "SAVE": "저장", + "SAVE_FAILED": "카테고리 표시 여부를 저장하지 못했습니다" + }, + "GLOBAL_SEARCH_SUMMARY": "재생목록 {{playlists}}개에서 항목 {{count}}개를 찾았습니다" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "지금 다시 시도" }, "ALL_CATEGORIES": "모든 카테고리", + "ALL_ITEMS": "모든 항목", + "UNNAMED_CATEGORY": "이름 없는 카테고리", + "NO_CATEGORIES": "사용 가능한 카테고리가 없습니다", + "UNTITLED": "제목 없음", "MY_FAVORITES": "내가 좋아하는 것들", "ADD_TO_FAVORITES": "즐겨 찾기에 추가", "REMOVE_FROM_FAVORITES": "즐겨 찾기에서 삭제하기", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "예고편 소리 켜기", "PROGRESS_RESET": "진행 상태를 초기화했습니다", "HIDDEN_FROM_CONTINUE_WATCHING": "‘이어서 보기’에서 숨겼습니다" + }, + "SCROLL_LEFT": "왼쪽으로 스크롤", + "SCROLL_RIGHT": "오른쪽으로 스크롤", + "EPISODE_NAVIGATION": "에피소드 탐색", + "INLINE_PLAYER": "플레이어", + "REQUEST_ERRORS": { + "XTREAM": "Xtream 요청에 실패했습니다: {{message}}", + "XTREAM_CONNECTION": "Xtream 서버에 연결하지 못했습니다", + "STALKER": "Stalker 요청에 실패했습니다: {{message}} (상태 {{status}})", + "NOT_FOUND": "찾을 수 없음" } }, "INFORMATION": "정보", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "재생목록에서 열기", "RAIL_ALL_CHANNELS": "모든 채널", "RAIL_GROUPS": "그룹", "RAIL_CONTEXT_ACTIONS": "컨텍스트 작업", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "PIN을 입력하여 표시", "HEADER_LOCK": "지금 잠그기(자녀 보호)", "HEADER_UNLOCK": "보호자 PIN 입력" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "{{player}}에서 여는 중…", + "STOP": "{{player}} 중지", + "ERRORS": { + "START_FAILED": "{{player}}을(를) 시작할 수 없습니다. 설치되어 있는지, 설정의 경로가 올바른지 확인하세요.", + "PREVIOUS_CLOSING": "이전 플레이어가 아직 종료되는 중입니다. 잠시 후 다시 시도하세요.", + "CLOSED_UNEXPECTEDLY": "{{player}}이(가) 예기치 않게 종료되었습니다.", + "STREAM_OPEN_FAILED": "{{player}}에서 스트림을 열 수 없습니다. URL이 잘못되었거나 서버가 응답하지 않을 수 있습니다.", + "UNSUPPORTED_PROTOCOL": "{{player}}에서 이 스트림 프로토콜을 지원하지 않습니다. 스트림 URL을 확인하세요.", + "CONNECTION_FAILED": "{{player}}에서 스트림 서버에 연결할 수 없습니다. 인터넷 연결을 확인하세요.", + "ACCESS_DENIED": "접근이 거부되었습니다. 스트림에 유효한 자격 증명이나 헤더가 필요할 수 있습니다.", + "STREAM_NOT_FOUND": "스트림을 찾을 수 없습니다. URL이 잘못되었거나 만료되었을 수 있습니다.", + "TIMED_OUT": "연결 시간이 초과되었습니다. 서버가 응답하지 않습니다.", + "LAUNCH_FAILED": "{{player}}을(를) 시작할 수 없습니다.", + "UNKNOWN": "{{player}} 오류: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "외부 플레이어 창에서 비디오를 재생하고 있습니다.", + "INSTALL_HINT": "시스템에 MPV 또는 VLC가 올바르게 설치되어 있는지 확인하세요.", + "INSTALL_INSTRUCTIONS": "설치 안내", + "DONT_SHOW_AGAIN": "다시 표시 안 함" + } } } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2e0762b59..4217a6bec 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream afspeellijst", "STALKER_PORTAL": "Stalker portal", "CHANNELS": "Kanalen", + "CHANNELS_COUNT": "Kanalen: {{count}}", "ADDED": "Toegevoegd", "SHOW_DETAILS": "Toon afspeellijstdetails", "REMOVE": "Verwijder afspeellijst", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC-adres", "PLAYLIST_EXPORT_SUCCESS": "Afspeellijst succesvol geëxporteerd.", "PLAYLIST_EXPORT_ERROR": "Fout: exporteren van afspeellijst mislukt.", + "EXPORT_PLAYLIST_FAILED": "Exporteren van afspeellijst mislukt.", "COPY_URL": "URL naar klembord kopiëren", "URL_COPIED": "URL naar klembord gekopieerd" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Bijgewerkt", "REFRESH": "Afspeellijst vernieuwen", + "REFRESH_FETCHING": "Afspeellijst ophalen…", + "REFRESH_READING_FILE": "Afspeellijstbestand lezen…", + "REFRESH_PARSING": "Afspeellijst verwerken…", + "REFRESH_SAVING": "Afspeellijst opslaan…", "REFRESH_XTREAM": "Xtream-afspeellijst vernieuwen vanaf de server", "REFRESH_XTREAM_DIALOG": { "TITLE": "Xtream-afspeellijst vernieuwen", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "Alle beschikbare afspeellijsten", "PLAYLIST_UPDATE_SUCCESS": "De afspeellijst is succesvol bijgewerkt.", "PLAYLIST_UPDATE_ERROR": "Fout bij het bijwerken van de afspeellijst.", + "PLAYLIST_UPDATE_FAILED": "De afspeellijst kon niet worden bijgewerkt.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Vernieuwen van afspeellijst mislukt. Het lokale bestand is niet meer beschikbaar. Controleer het bestandspad of importeer de afspeellijst opnieuw.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Vernieuwen van afspeellijst mislukt. De app heeft geen toegang meer tot het lokale bestand.", "AUTO_REFRESH_UPDATE_SUCCESS": "Gelukt! De afspeellijsten zijn succesvol bijgewerkt", @@ -166,6 +173,7 @@ "ERROR_403": "Toegang geweigerd (403): de server heeft het verzoek geweigerd. De URL vereist mogelijk authenticatie of de afspeellijst is beperkt.", "ERROR_404": "Afspeellijst niet gevonden (404): de URL verwijst niet naar een geldige afspeellijst. Controleer de URL.", "ERROR_401": "Niet geautoriseerd (401): authenticatie is vereist om deze afspeellijst te openen.", + "ERROR_413": "Dit bestand is te groot. Gebruik de zelfstandige of zelfgehoste versie van de app.", "ERROR_INVALID_TLS": "Het certificaat voor deze afspeellijsthost is ongeldig.", "ERROR_TLS_HOST_UNKNOWN": "Kan de afspeellijsthost niet bepalen. Probeer het handmatig opnieuw.", "TRUST_TLS_HOST": "Host vertrouwen", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importeren", "EXPORT_DATA": "Exporteren", "EXPORTING_DATA": "Exporteren…", + "BACKUP_EXPORTED": "Back-up van afspeellijsten geëxporteerd.", + "BACKUP_EXPORT_FAILED": "Exporteren van back-up van afspeellijsten mislukt.", "REMOVE_ALL": "Alle afspeellijsten en afspeellijstgegevens verwijderen", "REMOVE_ALL_BUTTON": "Alles verwijderen", "REMOVE_ALL_DESCRIPTION": "Verwijder elke M3U-, Xtream- en Stalker-afspeellijst die in de app is opgeslagen.", @@ -483,6 +493,7 @@ "CONFIRM": "Alles verwijderen" }, "IMPORT_ERROR": "Fout bij importeren, probeer een ander bestand.", + "BACKUP_IMPORT_SUMMARY": "Back-up importeren voltooid: {{imported}} geïmporteerd, {{merged}} samengevoegd, {{skipped}} overgeslagen, {{failed}} mislukt.", "PLAYLISTS_REMOVED": "Alle afspeellijsten en afspeellijstgegevens zijn verwijderd.", "MPV_PLAYER_PATH_LABEL": "mpv-pad", "MPV_PLAYER_PATH_DESCRIPTION": "Stel het pad naar MPV in. Op macOS kun je de MPV-appbundel gebruiken, zoals /Applications/mpv.app, of het pad naar het uitvoerbare bestand.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Volgende zender", "MUTE": "Dempen", "UNMUTE": "Dempen opheffen", - "VOLUME": "Volume" + "VOLUME": "Volume", + "RADIO": "Radio", + "PAUSED_BADGE": "PAUZE" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Sorteren op datum toegevoegd (Nieuwste eerst)", "SORT_BY_RATING": "Sorteren op beoordeling", "SORT_BY_NAME": "Sorteren op naam (A-Z)", + "SORT_CHANNELS": "Kanalen sorteren", + "SORT_SERVER_ORDER": "Servervolgorde", + "SORT_PLAYLIST_ORDER": "Afspeellijstvolgorde", "LOAD_MORE": "Meer laden", "REMAINING": "resterend", "LOADING": "Kanalen laden...", @@ -1132,6 +1148,7 @@ "DURATION": "Duur", "CAST": "Rolverdeling", "EPISODE_RUN_TIME": "Afleveringsduur", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} min/afl.", "RATING": "Score", "IMDB_RATING": "IMDb-beoordeling", "KINOPOISK_RATING": "Kinopoisk-beoordeling", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Zoek categorieën...", "NO_RESULTS": "Geen overeenkomende categorieën gevonden", "NO_CATEGORIES": "Geen categorieën beschikbaar", - "SAVE": "Opslaan" - } + "SAVE": "Opslaan", + "SAVE_FAILED": "Zichtbaarheid van categorieën kon niet worden opgeslagen" + }, + "GLOBAL_SEARCH_SUMMARY": "{{count}} item(s) gevonden in {{playlists}} afspeellijst(en)" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Nu opnieuw proberen" }, "ALL_CATEGORIES": "Alle categorieën", + "ALL_ITEMS": "Alle items", + "UNNAMED_CATEGORY": "Naamloze categorie", + "NO_CATEGORIES": "Geen categorieën beschikbaar", + "UNTITLED": "Zonder titel", "MY_FAVORITES": "Mijn favorieten", "ADD_TO_FAVORITES": "Voeg toe aan favorieten", "REMOVE_FROM_FAVORITES": "Uit favorieten verwijderen", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Trailergeluid aan", "PROGRESS_RESET": "Voortgang gewist", "HIDDEN_FROM_CONTINUE_WATCHING": "Verborgen uit ‘Verder kijken’" + }, + "SCROLL_LEFT": "Naar links scrollen", + "SCROLL_RIGHT": "Naar rechts scrollen", + "EPISODE_NAVIGATION": "Afleveringsnavigatie", + "INLINE_PLAYER": "Speler", + "REQUEST_ERRORS": { + "XTREAM": "Xtream-verzoek mislukt: {{message}}", + "XTREAM_CONNECTION": "Kan geen verbinding maken met de Xtream-server", + "STALKER": "Stalker-verzoek mislukt: {{message}} (status {{status}})", + "NOT_FOUND": "Niet gevonden" } }, "INFORMATION": "Informatie", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Openen in afspeellijst", "RAIL_ALL_CHANNELS": "Alle kanalen", "RAIL_GROUPS": "Groepen", "RAIL_CONTEXT_ACTIONS": "Sectieacties", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Voer de pincode in om te tonen", "HEADER_LOCK": "Nu vergrendelen (ouderlijk toezicht)", "HEADER_UNLOCK": "Ouderpincode invoeren" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Wordt geopend in {{player}}…", + "STOP": "{{player}} stoppen", + "ERRORS": { + "START_FAILED": "Kan {{player}} niet starten. Controleer of het is geïnstalleerd en of het pad in Instellingen juist is.", + "PREVIOUS_CLOSING": "De vorige speler wordt nog afgesloten. Probeer het zo opnieuw.", + "CLOSED_UNEXPECTEDLY": "{{player}} is onverwacht afgesloten.", + "STREAM_OPEN_FAILED": "{{player}} kan de stream niet openen. De URL is mogelijk ongeldig of de server reageert niet.", + "UNSUPPORTED_PROTOCOL": "{{player}} ondersteunt dit streamprotocol niet. Controleer de stream-URL.", + "CONNECTION_FAILED": "{{player}} kan geen verbinding maken met de streamserver. Controleer uw internetverbinding.", + "ACCESS_DENIED": "Toegang geweigerd. De stream vereist mogelijk geldige inloggegevens of headers.", + "STREAM_NOT_FOUND": "Stream niet gevonden. De URL is mogelijk onjuist of verlopen.", + "TIMED_OUT": "Time-out van de verbinding. De server reageert niet.", + "LAUNCH_FAILED": "Kan {{player}} niet starten.", + "UNKNOWN": "{{player}}-fout: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "De video wordt afgespeeld in het venster van de externe speler.", + "INSTALL_HINT": "Controleer of MPV of VLC correct op uw systeem is geïnstalleerd.", + "INSTALL_INSTRUCTIONS": "Installatie-instructies", + "DONT_SHOW_AGAIN": "Niet meer tonen" + } } } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 7efe3bb3d..140c13469 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Lista odtwarzania Xtream", "STALKER_PORTAL": "Portal Stalker", "CHANNELS": "Kanały", + "CHANNELS_COUNT": "{{count}} kanałów", "ADDED": "Dodano", "SHOW_DETAILS": "Pokaż szczegóły listy odtwarzania", "REMOVE": "Usuń listę odtwarzania", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "Adres MAC", "PLAYLIST_EXPORT_SUCCESS": "Lista odtwarzania została pomyślnie wyeksportowana.", "PLAYLIST_EXPORT_ERROR": "Błąd: Eksport listy odtwarzania nie powiódł się.", + "EXPORT_PLAYLIST_FAILED": "Eksport listy odtwarzania nie powiódł się.", "COPY_URL": "Kopiuj URL do schowka", "URL_COPIED": "URL skopiowany do schowka" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Zaktualizowano", "REFRESH": "Odśwież listę odtwarzania", + "REFRESH_FETCHING": "Pobieranie listy odtwarzania…", + "REFRESH_READING_FILE": "Odczytywanie pliku listy odtwarzania…", + "REFRESH_PARSING": "Analizowanie listy odtwarzania…", + "REFRESH_SAVING": "Zapisywanie listy odtwarzania…", "REFRESH_XTREAM": "Odśwież listę Xtream ze źródła zdalnego", "REFRESH_XTREAM_DIALOG": { "TITLE": "Odśwież listę odtwarzania Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "wszystkie dostępne listy odtwarzania", "PLAYLIST_UPDATE_SUCCESS": "Sukces! Lista odtwarzania została pomyślnie zaktualizowana.", "PLAYLIST_UPDATE_ERROR": "Błąd aktualizacji szczegółów listy odtwarzania.", + "PLAYLIST_UPDATE_FAILED": "Nie udało się zaktualizować listy odtwarzania.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Odświeżanie listy odtwarzania nie powiodło się. Plik lokalny jest niedostępny. Sprawdź ścieżkę pliku lub zaimportuj listę ponownie.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Odświeżanie listy odtwarzania nie powiodło się. Aplikacja nie ma już dostępu do pliku lokalnego.", "AUTO_REFRESH_UPDATE_SUCCESS": "Sukces! Listy odtwarzania zostały pomyślnie zaktualizowane", @@ -166,6 +173,7 @@ "ERROR_403": "Brak dostępu (403): Serwer odrzucił żądanie. URL może wymagać uwierzytelnienia lub lista jest ograniczona.", "ERROR_404": "Nie znaleziono listy odtwarzania (404): URL nie wskazuje prawidłowej listy. Sprawdź URL.", "ERROR_401": "Brak autoryzacji (401): Dostęp do tej listy odtwarzania wymaga uwierzytelnienia.", + "ERROR_413": "Ten plik jest za duży. Użyj samodzielnej wersji aplikacji lub wersji hostowanej na własnym serwerze.", "ERROR_INVALID_TLS": "Certyfikat hosta tej listy odtwarzania jest nieprawidłowy.", "ERROR_TLS_HOST_UNKNOWN": "Nie można ustalić hosta listy odtwarzania. Spróbuj ponownie ręcznie.", "TRUST_TLS_HOST": "Zaufaj hostowi", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importuj", "EXPORT_DATA": "Eksportuj", "EXPORTING_DATA": "Eksportowanie…", + "BACKUP_EXPORTED": "Wyeksportowano kopię zapasową list odtwarzania.", + "BACKUP_EXPORT_FAILED": "Eksport kopii zapasowej list odtwarzania nie powiódł się.", "REMOVE_ALL": "Usuń wszystkie listy odtwarzania i ich dane", "REMOVE_ALL_BUTTON": "Usuń wszystko", "REMOVE_ALL_DESCRIPTION": "Usuń każdą listę odtwarzania M3U, Xtream i Stalker zapisaną w aplikacji.", @@ -483,6 +493,7 @@ "CONFIRM": "Usuń wszystko" }, "IMPORT_ERROR": "Błąd importu, spróbuj z innym plikiem.", + "BACKUP_IMPORT_SUMMARY": "Import kopii zapasowej zakończony: zaimportowano {{imported}}, scalono {{merged}}, pominięto {{skipped}}, nie powiodło się {{failed}}.", "PLAYLISTS_REMOVED": "Wszystkie listy odtwarzania i ich dane zostały usunięte.", "MPV_PLAYER_PATH_LABEL": "Ścieżka mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Ustaw ścieżkę do MPV. W macOS możesz użyć pakietu aplikacji MPV, np. /Applications/mpv.app, albo ścieżki do pliku wykonywalnego.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Następna stacja", "MUTE": "Wycisz", "UNMUTE": "Wyłącz wyciszenie", - "VOLUME": "Głośność" + "VOLUME": "Głośność", + "RADIO": "Radio", + "PAUSED_BADGE": "PAUZA" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Sortuj według daty dodania (najnowsze pierwsze)", "SORT_BY_RATING": "Sortuj według oceny", "SORT_BY_NAME": "Sortuj według nazwy (A-Z)", + "SORT_CHANNELS": "Sortuj kanały", + "SORT_SERVER_ORDER": "Kolejność serwera", + "SORT_PLAYLIST_ORDER": "Kolejność listy odtwarzania", "LOAD_MORE": "Załaduj więcej", "REMAINING": "pozostało", "LOADING": "Ładowanie kanałów...", @@ -1132,6 +1148,7 @@ "DURATION": "Czas trwania", "CAST": "Obsada", "EPISODE_RUN_TIME": "Czas trwania odcinka", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} min/odc.", "RATING": "Ocena", "IMDB_RATING": "Ocena IMDb", "KINOPOISK_RATING": "Ocena Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Szukaj kategorii...", "NO_RESULTS": "Nie znaleziono pasujących kategorii", "NO_CATEGORIES": "Brak dostępnych kategorii", - "SAVE": "Zapisz" - } + "SAVE": "Zapisz", + "SAVE_FAILED": "Nie udało się zapisać widoczności kategorii" + }, + "GLOBAL_SEARCH_SUMMARY": "Znaleziono {{count}} pozycji na listach odtwarzania ({{playlists}})" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Ponów teraz" }, "ALL_CATEGORIES": "Wszystkie kategorie", + "ALL_ITEMS": "Wszystkie pozycje", + "UNNAMED_CATEGORY": "Nienazwana kategoria", + "NO_CATEGORIES": "Brak dostępnych kategorii", + "UNTITLED": "Bez tytułu", "MY_FAVORITES": "Moje ulubione", "ADD_TO_FAVORITES": "Dodaj do ulubionych", "REMOVE_FROM_FAVORITES": "Usuń z ulubionych", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Włącz dźwięk zwiastuna", "PROGRESS_RESET": "Postęp zresetowany", "HIDDEN_FROM_CONTINUE_WATCHING": "Ukryto z „Kontynuuj oglądanie”" + }, + "SCROLL_LEFT": "Przewiń w lewo", + "SCROLL_RIGHT": "Przewiń w prawo", + "EPISODE_NAVIGATION": "Nawigacja po odcinkach", + "INLINE_PLAYER": "Odtwarzacz", + "REQUEST_ERRORS": { + "XTREAM": "Żądanie Xtream nie powiodło się: {{message}}", + "XTREAM_CONNECTION": "Nie udało się połączyć z serwerem Xtream", + "STALKER": "Żądanie Stalker nie powiodło się: {{message}} (status {{status}})", + "NOT_FOUND": "Nie znaleziono" } }, "INFORMATION": "Informacje", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Otwórz na liście odtwarzania", "RAIL_ALL_CHANNELS": "Wszystkie kanały", "RAIL_GROUPS": "Grupy", "RAIL_CONTEXT_ACTIONS": "Akcje sekcji", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Wpisz PIN, aby pokazać", "HEADER_LOCK": "Zablokuj teraz (kontrola rodzicielska)", "HEADER_UNLOCK": "Wpisz rodzicielski kod PIN" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Otwieranie w {{player}}…", + "STOP": "Zatrzymaj {{player}}", + "ERRORS": { + "START_FAILED": "Nie udało się uruchomić {{player}}. Upewnij się, że jest zainstalowany, a jego ścieżka w ustawieniach jest poprawna.", + "PREVIOUS_CLOSING": "Poprzedni odtwarzacz wciąż się zamyka. Spróbuj ponownie za chwilę.", + "CLOSED_UNEXPECTEDLY": "{{player}} został nieoczekiwanie zamknięty.", + "STREAM_OPEN_FAILED": "{{player}} nie może otworzyć strumienia. URL może być nieprawidłowy lub serwer nie odpowiada.", + "UNSUPPORTED_PROTOCOL": "{{player}} nie obsługuje protokołu tego strumienia. Sprawdź URL strumienia.", + "CONNECTION_FAILED": "{{player}} nie może połączyć się z serwerem strumienia. Sprawdź połączenie z internetem.", + "ACCESS_DENIED": "Odmowa dostępu. Strumień może wymagać prawidłowych danych uwierzytelniających lub nagłówków.", + "STREAM_NOT_FOUND": "Nie znaleziono strumienia. URL może być nieprawidłowy lub wygasł.", + "TIMED_OUT": "Przekroczono limit czasu połączenia. Serwer nie odpowiada.", + "LAUNCH_FAILED": "Nie udało się uruchomić {{player}}.", + "UNKNOWN": "Błąd {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "Wideo jest odtwarzane w oknie zewnętrznego odtwarzacza.", + "INSTALL_HINT": "Upewnij się, że MPV lub VLC jest poprawnie zainstalowany w systemie.", + "INSTALL_INSTRUCTIONS": "Instrukcje instalacji", + "DONT_SHOW_AGAIN": "Nie pokazuj ponownie" + } } } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index f3e5403bf..277db4436 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Playlist Xtream", "STALKER_PORTAL": "Portal Stalker", "CHANNELS": "Canais", + "CHANNELS_COUNT": "Canais: {{count}}", "ADDED": "Adicionado", "SHOW_DETAILS": "Mostrar detalhes da playlist", "REMOVE": "Remover playlist", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "Endereço MAC", "PLAYLIST_EXPORT_SUCCESS": "Playlist foi exportada com sucesso.", "PLAYLIST_EXPORT_ERROR": "Erro: Falha ao exportar a playlist.", + "EXPORT_PLAYLIST_FAILED": "Falha ao exportar a playlist.", "COPY_URL": "Copiar URL para a área de transferência", "URL_COPIED": "URL copiada para a área de transferência" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Atualizado", "REFRESH": "Atualizar playlist", + "REFRESH_FETCHING": "Buscando playlist…", + "REFRESH_READING_FILE": "Lendo arquivo da playlist…", + "REFRESH_PARSING": "Analisando playlist…", + "REFRESH_SAVING": "Salvando playlist…", "REFRESH_XTREAM": "Atualizar playlist Xtream do servidor", "REFRESH_XTREAM_DIALOG": { "TITLE": "Atualizar playlist Xtream", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "todas as playlists disponíveis", "PLAYLIST_UPDATE_SUCCESS": "Sucesso! A playlist foi atualizada com sucesso.", "PLAYLIST_UPDATE_ERROR": "Erro ao atualizar detalhes da playlist.", + "PLAYLIST_UPDATE_FAILED": "Não foi possível atualizar a playlist.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Falha ao atualizar a playlist. O arquivo local não está mais disponível. Verifique o caminho do arquivo ou reimporte a playlist.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Falha ao atualizar a playlist. O aplicativo não consegue mais acessar o arquivo local.", "AUTO_REFRESH_UPDATE_SUCCESS": "Sucesso! As playlists foram atualizadas com sucesso", @@ -166,6 +173,7 @@ "ERROR_403": "Acesso negado (403): O servidor recusou a solicitação. A URL pode exigir autenticação ou a playlist está restrita.", "ERROR_404": "Playlist não encontrada (404): A URL não aponta para uma playlist válida. Verifique a URL.", "ERROR_401": "Não autorizado (401): É necessária autenticação para acessar esta playlist.", + "ERROR_413": "Este arquivo é grande demais. Use a versão desktop ou auto-hospedada do aplicativo.", "ERROR_INVALID_TLS": "O certificado deste host da playlist é inválido.", "ERROR_TLS_HOST_UNKNOWN": "Não foi possível determinar o host da playlist. Tente novamente manualmente.", "TRUST_TLS_HOST": "Confiar no host", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Importar", "EXPORT_DATA": "Exportar", "EXPORTING_DATA": "Exportando…", + "BACKUP_EXPORTED": "Backup de playlists exportado.", + "BACKUP_EXPORT_FAILED": "Falha ao exportar o backup de playlists.", "REMOVE_ALL": "Excluir todas as playlists e dados de playlists", "REMOVE_ALL_BUTTON": "Excluir tudo", "REMOVE_ALL_DESCRIPTION": "Exclua todas as playlists M3U, Xtream e Stalker armazenadas no app.", @@ -483,6 +493,7 @@ "CONFIRM": "Excluir tudo" }, "IMPORT_ERROR": "Erro de importação, tente com outro arquivo.", + "BACKUP_IMPORT_SUMMARY": "Importação do backup concluída — importadas: {{imported}}, mescladas: {{merged}}, ignoradas: {{skipped}}, com falha: {{failed}}.", "PLAYLISTS_REMOVED": "Todas as playlists e dados de playlists foram removidos.", "MPV_PLAYER_PATH_LABEL": "Caminho do mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Defina o caminho para o MPV. No macOS você pode usar o pacote do app MPV, como /Applications/mpv.app, ou o caminho do executável.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Próxima estação", "MUTE": "Silenciar", "UNMUTE": "Ativar som", - "VOLUME": "Volume" + "VOLUME": "Volume", + "RADIO": "Rádio", + "PAUSED_BADGE": "PAUSADO" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Ordenar por Data de Adição (Mais Recente Primeiro)", "SORT_BY_RATING": "Ordenar por Classificação", "SORT_BY_NAME": "Ordenar por Nome (A-Z)", + "SORT_CHANNELS": "Ordenar canais", + "SORT_SERVER_ORDER": "Ordem do servidor", + "SORT_PLAYLIST_ORDER": "Ordem da playlist", "LOAD_MORE": "Carregar mais", "REMAINING": "restantes", "LOADING": "Carregando canais...", @@ -1132,6 +1148,7 @@ "DURATION": "Duração", "CAST": "Elenco", "EPISODE_RUN_TIME": "Duração do episódio", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} min/ep.", "RATING": "Classificação", "IMDB_RATING": "Avaliação IMDb", "KINOPOISK_RATING": "Avaliação Kinopoisk", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Pesquisar categorias...", "NO_RESULTS": "Nenhuma categoria correspondente encontrada", "NO_CATEGORIES": "Nenhuma categoria disponível", - "SAVE": "Salvar" - } + "SAVE": "Salvar", + "SAVE_FAILED": "Falha ao salvar a visibilidade das categorias" + }, + "GLOBAL_SEARCH_SUMMARY": "Itens encontrados: {{count}} · Playlists: {{playlists}}" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Tentar novamente agora" }, "ALL_CATEGORIES": "Todas as categorias", + "ALL_ITEMS": "Todos os itens", + "UNNAMED_CATEGORY": "Categoria sem nome", + "NO_CATEGORIES": "Nenhuma categoria disponível", + "UNTITLED": "Sem título", "MY_FAVORITES": "Meus favoritos", "ADD_TO_FAVORITES": "Adicionar aos favoritos", "REMOVE_FROM_FAVORITES": "Remover dos favoritos", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Ativar som do trailer", "PROGRESS_RESET": "Progresso redefinido", "HIDDEN_FROM_CONTINUE_WATCHING": "Oculto de «Continuar assistindo»" + }, + "SCROLL_LEFT": "Rolar para a esquerda", + "SCROLL_RIGHT": "Rolar para a direita", + "EPISODE_NAVIGATION": "Navegação de episódios", + "INLINE_PLAYER": "Reprodutor", + "REQUEST_ERRORS": { + "XTREAM": "Falha na solicitação Xtream: {{message}}", + "XTREAM_CONNECTION": "Falha ao conectar ao servidor Xtream", + "STALKER": "Falha na solicitação Stalker: {{message}} (status {{status}})", + "NOT_FOUND": "Não encontrado" } }, "INFORMATION": "Informação", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Abrir na playlist", "RAIL_ALL_CHANNELS": "Todos os canais", "RAIL_GROUPS": "Grupos", "RAIL_CONTEXT_ACTIONS": "Ações de contexto", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Digite o PIN para mostrar", "HEADER_LOCK": "Bloquear agora (controle parental)", "HEADER_UNLOCK": "Digitar o PIN parental" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Abrindo no {{player}}…", + "STOP": "Parar {{player}}", + "ERRORS": { + "START_FAILED": "Não foi possível iniciar o {{player}}. Verifique se ele está instalado e se o caminho nas Configurações está correto.", + "PREVIOUS_CLOSING": "O reprodutor anterior ainda está sendo fechado. Tente novamente em instantes.", + "CLOSED_UNEXPECTEDLY": "O {{player}} fechou inesperadamente.", + "STREAM_OPEN_FAILED": "O {{player}} não conseguiu abrir o stream. A URL pode ser inválida ou o servidor não está respondendo.", + "UNSUPPORTED_PROTOCOL": "O {{player}} não suporta o protocolo deste stream. Verifique a URL do stream.", + "CONNECTION_FAILED": "O {{player}} não consegue se conectar ao servidor do stream. Verifique sua conexão com a internet.", + "ACCESS_DENIED": "Acesso negado. O stream pode exigir credenciais ou cabeçalhos válidos.", + "STREAM_NOT_FOUND": "Stream não encontrado. A URL pode estar incorreta ou expirada.", + "TIMED_OUT": "Tempo de conexão esgotado. O servidor não está respondendo.", + "LAUNCH_FAILED": "Não foi possível iniciar o {{player}}.", + "UNKNOWN": "Erro do {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "O vídeo está sendo reproduzido na janela do reprodutor externo.", + "INSTALL_HINT": "Verifique se o MPV ou o VLC está instalado corretamente no seu sistema.", + "INSTALL_INSTRUCTIONS": "Instruções de instalação", + "DONT_SHOW_AGAIN": "Não mostrar novamente" + } } } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 0a01750c3..f2acf62b3 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream плейлист", "STALKER_PORTAL": "Stalker портал", "CHANNELS": "Каналов", + "CHANNELS_COUNT": "Каналов: {{count}}", "ADDED": "Добавлен", "SHOW_DETAILS": "Показать информацию о плейлисте", "REMOVE": "Удалить плейлист", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC-адрес", "PLAYLIST_EXPORT_SUCCESS": "Плейлист был успешно экспортирован.", "PLAYLIST_EXPORT_ERROR": "Ошибка: не удалось экспортировать плейлист.", + "EXPORT_PLAYLIST_FAILED": "Не удалось экспортировать плейлист.", "COPY_URL": "Скопировать URL в буфер обмена", "URL_COPIED": "URL скопирован в буфер обмена" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Обновлен", "REFRESH": "Обновить плейлист", + "REFRESH_FETCHING": "Получение плейлиста…", + "REFRESH_READING_FILE": "Чтение файла плейлиста…", + "REFRESH_PARSING": "Разбор плейлиста…", + "REFRESH_SAVING": "Сохранение плейлиста…", "REFRESH_XTREAM": "Обновить Xtream плейлист с сервера", "REFRESH_XTREAM_DIALOG": { "TITLE": "Обновить Xtream плейлист", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "Все имеющиеся плейлисты", "PLAYLIST_UPDATE_SUCCESS": "Плейлист был успешно обновлен!", "PLAYLIST_UPDATE_ERROR": "Ошибка при обновлении деталей плейлиста.", + "PLAYLIST_UPDATE_FAILED": "Не удалось обновить плейлист.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Не удалось обновить плейлист. Локальный файл больше не доступен. Проверьте путь к файлу или импортируйте плейлист заново.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Не удалось обновить плейлист. Приложение больше не имеет доступа к локальному файлу.", "AUTO_REFRESH_UPDATE_SUCCESS": "Успешно! Плейлисты были успешно обновлены", @@ -166,6 +173,7 @@ "ERROR_403": "Доступ запрещён (403): сервер отклонил запрос. Возможно, URL требует аутентификации или плейлист ограничен.", "ERROR_404": "Плейлист не найден (404): URL не указывает на действительный плейлист. Проверьте URL.", "ERROR_401": "Не авторизовано (401): для доступа к этому плейлисту требуется аутентификация.", + "ERROR_413": "Этот файл слишком большой. Используйте настольную версию приложения или версию на собственном сервере.", "ERROR_INVALID_TLS": "Сертификат этого хоста плейлиста недействителен.", "ERROR_TLS_HOST_UNKNOWN": "Не удалось определить хост плейлиста. Повторите попытку вручную.", "TRUST_TLS_HOST": "Доверять хосту", @@ -466,6 +474,8 @@ "IMPORT_DATA": "Импорт", "EXPORT_DATA": "Экспорт", "EXPORTING_DATA": "Экспорт…", + "BACKUP_EXPORTED": "Резервная копия плейлистов экспортирована.", + "BACKUP_EXPORT_FAILED": "Не удалось экспортировать резервную копию плейлистов.", "REMOVE_ALL": "Удалить все плейлисты и данные плейлистов", "REMOVE_ALL_BUTTON": "Удалить всё", "REMOVE_ALL_DESCRIPTION": "Удалить все плейлисты M3U, Xtream и Stalker, сохранённые в приложении.", @@ -483,6 +493,7 @@ "CONFIRM": "Удалить всё" }, "IMPORT_ERROR": "Ошибка импорта, пожалуйста попробуйте импортировать другой файл.", + "BACKUP_IMPORT_SUMMARY": "Импорт резервной копии завершён. Импортировано: {{imported}}, объединено: {{merged}}, пропущено: {{skipped}}, ошибок: {{failed}}.", "PLAYLISTS_REMOVED": "Все плейлисты и данные плейлистов были удалены.", "MPV_PLAYER_PATH_LABEL": "Путь к mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Укажите путь к MPV. В macOS можно использовать пакет приложения MPV, например /Applications/mpv.app, или путь к исполняемому файлу.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Следующая станция", "MUTE": "Отключить звук", "UNMUTE": "Включить звук", - "VOLUME": "Громкость" + "VOLUME": "Громкость", + "RADIO": "Радио", + "PAUSED_BADGE": "ПАУЗА" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Сортировать по дате добавления (сначала новые)", "SORT_BY_RATING": "Сортировать по рейтингу", "SORT_BY_NAME": "Сортировать по имени (А-Я)", + "SORT_CHANNELS": "Сортировать каналы", + "SORT_SERVER_ORDER": "Порядок сервера", + "SORT_PLAYLIST_ORDER": "Порядок плейлиста", "LOAD_MORE": "Загрузить ещё", "REMAINING": "осталось", "LOADING": "Загрузка каналов…", @@ -1132,6 +1148,7 @@ "DURATION": "Длительность", "CAST": "Актёры", "EPISODE_RUN_TIME": "Длительность эпизода", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} мин/серия", "RATING": "Рейтинг", "IMDB_RATING": "Рейтинг IMDb", "KINOPOISK_RATING": "Рейтинг Кинопоиска", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Поиск категорий…", "NO_RESULTS": "Подходящих категорий не найдено", "NO_CATEGORIES": "Нет доступных категорий", - "SAVE": "Сохранить" - } + "SAVE": "Сохранить", + "SAVE_FAILED": "Не удалось сохранить видимость категорий" + }, + "GLOBAL_SEARCH_SUMMARY": "Найдено элементов: {{count}} (плейлистов: {{playlists}})" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Повторить сейчас" }, "ALL_CATEGORIES": "Все категории", + "ALL_ITEMS": "Все элементы", + "UNNAMED_CATEGORY": "Категория без названия", + "NO_CATEGORIES": "Нет доступных категорий", + "UNTITLED": "Без названия", "MY_FAVORITES": "Мои избранные", "ADD_TO_FAVORITES": "Добавить в избранное", "REMOVE_FROM_FAVORITES": "Удалить из избранного", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Включить звук трейлера", "PROGRESS_RESET": "Прогресс сброшен", "HIDDEN_FROM_CONTINUE_WATCHING": "Скрыто из «Продолжить просмотр»" + }, + "SCROLL_LEFT": "Прокрутить влево", + "SCROLL_RIGHT": "Прокрутить вправо", + "EPISODE_NAVIGATION": "Навигация по эпизодам", + "INLINE_PLAYER": "Плеер", + "REQUEST_ERRORS": { + "XTREAM": "Ошибка запроса Xtream: {{message}}", + "XTREAM_CONNECTION": "Не удалось подключиться к серверу Xtream", + "STALKER": "Ошибка запроса Stalker: {{message}} (статус {{status}})", + "NOT_FOUND": "Не найдено" } }, "INFORMATION": "Инфо", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Воспроизведение", "EXTERNAL_PLAYBACK_FAILED": "Ошибка внешнего плеера", "EXTERNAL_PLAYBACK_CLOSED": "Закрыт", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Открыть в плейлисте", "RAIL_ALL_CHANNELS": "Все каналы", "RAIL_GROUPS": "Группы", "RAIL_CONTEXT_ACTIONS": "Действия раздела", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Введите PIN, чтобы показать", "HEADER_LOCK": "Заблокировать сейчас (родительский контроль)", "HEADER_UNLOCK": "Введите родительский PIN" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "Открытие в {{player}}…", + "STOP": "Остановить {{player}}", + "ERRORS": { + "START_FAILED": "Не удалось запустить {{player}}. Убедитесь, что он установлен и путь к нему в настройках указан верно.", + "PREVIOUS_CLOSING": "Предыдущий плеер ещё закрывается. Попробуйте ещё раз через несколько секунд.", + "CLOSED_UNEXPECTEDLY": "{{player}} неожиданно закрылся.", + "STREAM_OPEN_FAILED": "{{player}} не удалось открыть поток. Возможно, URL недействителен или сервер не отвечает.", + "UNSUPPORTED_PROTOCOL": "{{player}} не поддерживает протокол этого потока. Проверьте URL потока.", + "CONNECTION_FAILED": "{{player}} не может подключиться к серверу потока. Проверьте подключение к интернету.", + "ACCESS_DENIED": "Доступ запрещён. Возможно, для потока требуются действительные учётные данные или заголовки.", + "STREAM_NOT_FOUND": "Поток не найден. Возможно, URL указан неверно или срок его действия истёк.", + "TIMED_OUT": "Истекло время ожидания подключения. Сервер не отвечает.", + "LAUNCH_FAILED": "Не удалось запустить {{player}}.", + "UNKNOWN": "Ошибка {{player}}: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "Видео воспроизводится в окне внешнего плеера.", + "INSTALL_HINT": "Убедитесь, что MPV или VLC правильно установлен в вашей системе.", + "INSTALL_INSTRUCTIONS": "Инструкции по установке", + "DONT_SHOW_AGAIN": "Больше не показывать" + } } } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 0e22c7e16..22debd17b 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream oynatma listesi", "STALKER_PORTAL": "Stalker portalı", "CHANNELS": "Kanallar", + "CHANNELS_COUNT": "{{count}} kanal", "ADDED": "Eklendi", "SHOW_DETAILS": "Oynatma listesi ayrıntılarını göster", "REMOVE": "Oynatma listesini kaldır", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC adresi", "PLAYLIST_EXPORT_SUCCESS": "Oynatma listesi başarıyla dışa aktarıldı.", "PLAYLIST_EXPORT_ERROR": "Hata: Oynatma listesi dışa aktarılamadı.", + "EXPORT_PLAYLIST_FAILED": "Oynatma listesi dışa aktarılamadı.", "COPY_URL": "URL'yi panoya kopyala", "URL_COPIED": "URL panoya kopyalandı" }, @@ -107,6 +109,10 @@ }, "UPDATED": "Güncellendi", "REFRESH": "Oynatma listesini yenile", + "REFRESH_FETCHING": "Oynatma listesi alınıyor…", + "REFRESH_READING_FILE": "Oynatma listesi dosyası okunuyor…", + "REFRESH_PARSING": "Oynatma listesi ayrıştırılıyor…", + "REFRESH_SAVING": "Oynatma listesi kaydediliyor…", "REFRESH_XTREAM": "Xtream oynatma listesini uzaktan yenile", "REFRESH_XTREAM_DIALOG": { "TITLE": "Xtream Oynatma Listesini Yenile", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "tüm oynatma listeleri", "PLAYLIST_UPDATE_SUCCESS": "Başarılı! Oynatma listesi başarıyla güncellendi.", "PLAYLIST_UPDATE_ERROR": "Oynatma listesi ayrıntıları güncellenirken hata oluştu.", + "PLAYLIST_UPDATE_FAILED": "Oynatma listesi güncellenemedi.", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "Oynatma listesi yenileme başarısız oldu. Yerel dosya artık kullanılabilir değil. Dosya yolunu kontrol edin veya oynatma listesini yeniden içe aktarın.", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Oynatma listesi yenileme başarısız oldu. Uygulama artık yerel dosyaya erişemiyor.", "AUTO_REFRESH_UPDATE_SUCCESS": "Başarılı! Oynatma listeleri başarıyla güncellendi", @@ -166,6 +173,7 @@ "ERROR_403": "Erişim reddedildi (403): Sunucu isteği reddetti. URL kimlik doğrulama gerektiriyor olabilir veya oynatma listesi kısıtlanmış olabilir.", "ERROR_404": "Oynatma listesi bulunamadı (404): URL geçerli bir oynatma listesine işaret etmiyor. Lütfen URL'yi kontrol edin.", "ERROR_401": "Yetkisiz (401): Bu oynatma listesine erişmek için kimlik doğrulama gerekiyor.", + "ERROR_413": "Bu dosya çok büyük. Uygulamanın bağımsız veya kendi sunucunuzda barındırılan sürümünü kullanın.", "ERROR_INVALID_TLS": "Bu oynatma listesi ana bilgisayarının sertifikası geçersiz.", "ERROR_TLS_HOST_UNKNOWN": "Oynatma listesi ana bilgisayarı belirlenemedi. Lütfen el ile yeniden deneyin.", "TRUST_TLS_HOST": "Ana bilgisayara güven", @@ -466,6 +474,8 @@ "IMPORT_DATA": "İçe aktar", "EXPORT_DATA": "Dışa aktar", "EXPORTING_DATA": "Dışa aktarılıyor…", + "BACKUP_EXPORTED": "Oynatma listesi yedeği dışa aktarıldı.", + "BACKUP_EXPORT_FAILED": "Oynatma listesi yedeği dışa aktarılamadı.", "REMOVE_ALL": "Tüm oynatma listelerini ve oynatma listesi verilerini sil", "REMOVE_ALL_BUTTON": "Tümünü sil", "REMOVE_ALL_DESCRIPTION": "Uygulamada saklanan tüm M3U, Xtream ve Stalker oynatma listelerini silin.", @@ -483,6 +493,7 @@ "CONFIRM": "Her şeyi sil" }, "IMPORT_ERROR": "İçe aktarma hatası, lütfen başka bir dosyayla deneyin.", + "BACKUP_IMPORT_SUMMARY": "Yedek içe aktarma tamamlandı: {{imported}} içe aktarıldı, {{merged}} birleştirildi, {{skipped}} atlandı, {{failed}} başarısız.", "PLAYLISTS_REMOVED": "Tüm oynatma listeleri ve oynatma listesi verileri kaldırıldı.", "MPV_PLAYER_PATH_LABEL": "mpv yolu", "MPV_PLAYER_PATH_DESCRIPTION": "MPV yolunu ayarlayın. macOS’ta /Applications/mpv.app gibi MPV uygulama paketini veya yürütülebilir dosya yolunu kullanabilirsiniz.", @@ -623,7 +634,9 @@ "NEXT_STATION": "Sonraki istasyon", "MUTE": "Sesi kapat", "UNMUTE": "Sesi aç", - "VOLUME": "Ses seviyesi" + "VOLUME": "Ses seviyesi", + "RADIO": "Radyo", + "PAUSED_BADGE": "DURAKLATILDI" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "Eklenme tarihine göre sırala (Önce En Yeni)", "SORT_BY_RATING": "Puana göre sırala", "SORT_BY_NAME": "İsme göre sırala (A-Z)", + "SORT_CHANNELS": "Kanalları sırala", + "SORT_SERVER_ORDER": "Sunucu sıralaması", + "SORT_PLAYLIST_ORDER": "Oynatma listesi sıralaması", "LOAD_MORE": "Daha fazla yükle", "REMAINING": "kalan", "LOADING": "Kanallar yükleniyor...", @@ -1132,6 +1148,7 @@ "DURATION": "Süre", "CAST": "Oyuncular", "EPISODE_RUN_TIME": "Bölüm süresi", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} dk/bölüm", "RATING": "Değerlendirme", "IMDB_RATING": "IMDb puanı", "KINOPOISK_RATING": "Kinopoisk puanı", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "Kategorileri ara...", "NO_RESULTS": "Eşleşen kategori bulunamadı", "NO_CATEGORIES": "Kategori mevcut değil", - "SAVE": "Kaydet" - } + "SAVE": "Kaydet", + "SAVE_FAILED": "Kategori görünürlüğü kaydedilemedi" + }, + "GLOBAL_SEARCH_SUMMARY": "{{playlists}} oynatma listesinde {{count}} öğe bulundu" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "Şimdi yeniden dene" }, "ALL_CATEGORIES": "Tüm kategoriler", + "ALL_ITEMS": "Tüm öğeler", + "UNNAMED_CATEGORY": "İsimsiz kategori", + "NO_CATEGORIES": "Kategori mevcut değil", + "UNTITLED": "Adsız", "MY_FAVORITES": "Favorilerim", "ADD_TO_FAVORITES": "Favorilere ekle", "REMOVE_FROM_FAVORITES": "Favorilerden kaldır", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "Fragman sesini aç", "PROGRESS_RESET": "İlerleme sıfırlandı", "HIDDEN_FROM_CONTINUE_WATCHING": "“İzlemeye devam et”ten gizlendi" + }, + "SCROLL_LEFT": "Sola kaydır", + "SCROLL_RIGHT": "Sağa kaydır", + "EPISODE_NAVIGATION": "Bölüm gezinmesi", + "INLINE_PLAYER": "Oynatıcı", + "REQUEST_ERRORS": { + "XTREAM": "Xtream isteği başarısız oldu: {{message}}", + "XTREAM_CONNECTION": "Xtream sunucusuna bağlanılamadı", + "STALKER": "Stalker isteği başarısız oldu: {{message}} (durum {{status}})", + "NOT_FOUND": "Bulunamadı" } }, "INFORMATION": "Bilgi", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "Oynatma listesinde aç", "RAIL_ALL_CHANNELS": "Tüm kanallar", "RAIL_GROUPS": "Gruplar", "RAIL_CONTEXT_ACTIONS": "Bölüm eylemleri", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "Göstermek için PIN girin", "HEADER_LOCK": "Şimdi kilitle (ebeveyn kilidi)", "HEADER_UNLOCK": "Ebeveyn PIN'ini gir" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "{{player}} ile açılıyor…", + "STOP": "{{player}} oynatıcısını durdur", + "ERRORS": { + "START_FAILED": "{{player}} başlatılamadı. Kurulu olduğundan ve Ayarlar’daki yolunun doğru olduğundan emin olun.", + "PREVIOUS_CLOSING": "Önceki oynatıcı hâlâ kapanıyor. Birazdan tekrar deneyin.", + "CLOSED_UNEXPECTEDLY": "{{player}} beklenmedik şekilde kapandı.", + "STREAM_OPEN_FAILED": "{{player}} yayını açamadı. URL geçersiz olabilir veya sunucu yanıt vermiyor.", + "UNSUPPORTED_PROTOCOL": "{{player}} bu yayın protokolünü desteklemiyor. Yayın URL’sini kontrol edin.", + "CONNECTION_FAILED": "{{player}} yayın sunucusuna bağlanamıyor. İnternet bağlantınızı kontrol edin.", + "ACCESS_DENIED": "Erişim reddedildi. Yayın geçerli kimlik bilgileri veya başlıklar gerektirebilir.", + "STREAM_NOT_FOUND": "Yayın bulunamadı. URL yanlış olabilir veya süresi dolmuş olabilir.", + "TIMED_OUT": "Bağlantı zaman aşımına uğradı. Sunucu yanıt vermiyor.", + "LAUNCH_FAILED": "{{player}} başlatılamadı.", + "UNKNOWN": "{{player}} hatası: {{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "Video harici oynatıcı penceresinde oynatılıyor.", + "INSTALL_HINT": "MPV veya VLC’nin sisteminizde doğru şekilde kurulu olduğundan emin olun.", + "INSTALL_INSTRUCTIONS": "Kurulum talimatları", + "DONT_SHOW_AGAIN": "Bir daha gösterme" + } } } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index d557b6d4a..96639d926 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream 播放列表", "STALKER_PORTAL": "Stalker 门户", "CHANNELS": "频道", + "CHANNELS_COUNT": "{{count}} 个频道", "ADDED": "已添加的", "SHOW_DETAILS": "显示播放列表详情", "REMOVE": "移除播放列表", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC 地址", "PLAYLIST_EXPORT_SUCCESS": "播放列表已成功导出。", "PLAYLIST_EXPORT_ERROR": "错误:播放列表导出失败。", + "EXPORT_PLAYLIST_FAILED": "播放列表导出失败。", "COPY_URL": "复制 URL 到剪贴板", "URL_COPIED": "URL 已复制到剪贴板" }, @@ -107,6 +109,10 @@ }, "UPDATED": "更新", "REFRESH": "刷新播放列表", + "REFRESH_FETCHING": "正在获取播放列表…", + "REFRESH_READING_FILE": "正在读取播放列表文件…", + "REFRESH_PARSING": "正在解析播放列表…", + "REFRESH_SAVING": "正在保存播放列表…", "REFRESH_XTREAM": "从远程刷新 Xtream 播放列表", "REFRESH_XTREAM_DIALOG": { "TITLE": "刷新 Xtream 播放列表", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "所有可用的播放列表", "PLAYLIST_UPDATE_SUCCESS": "成功!播放列表已成功更新。", "PLAYLIST_UPDATE_ERROR": "更新播放列表详情时出错。", + "PLAYLIST_UPDATE_FAILED": "无法更新播放列表。", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "播放列表刷新失败。本地文件已不可用。请检查文件路径或重新导入播放列表。", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "播放列表刷新失败。应用程序无法再访问该本地文件。", "AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放列表已成功更新", @@ -166,6 +173,7 @@ "ERROR_403": "拒绝访问 (403):服务器拒绝了请求。该 URL 可能需要身份验证,或播放列表受到限制。", "ERROR_404": "未找到播放列表 (404):该 URL 不指向有效的播放列表。请检查 URL。", "ERROR_401": "未授权 (401):访问此播放列表需要进行身份验证。", + "ERROR_413": "此文件过大。请使用本应用的独立版或自托管版。", "ERROR_INVALID_TLS": "此播放列表主机的证书无效。", "ERROR_TLS_HOST_UNKNOWN": "无法确定播放列表主机。请手动重试。", "TRUST_TLS_HOST": "信任主机", @@ -466,6 +474,8 @@ "IMPORT_DATA": "导入", "EXPORT_DATA": "导出", "EXPORTING_DATA": "导出中…", + "BACKUP_EXPORTED": "播放列表备份已导出。", + "BACKUP_EXPORT_FAILED": "播放列表备份导出失败。", "REMOVE_ALL": "删除所有播放列表和播放列表数据", "REMOVE_ALL_BUTTON": "全部删除", "REMOVE_ALL_DESCRIPTION": "删除应用中存储的每个 M3U、Xtream 和 Stalker 播放列表。", @@ -483,6 +493,7 @@ "CONFIRM": "全部删除" }, "IMPORT_ERROR": "导入错误,请尝试使用其他文件。", + "BACKUP_IMPORT_SUMMARY": "备份导入完成:已导入 {{imported}} 个,已合并 {{merged}} 个,已跳过 {{skipped}} 个,失败 {{failed}} 个。", "PLAYLISTS_REMOVED": "已移除所有播放列表和播放列表数据。", "MPV_PLAYER_PATH_LABEL": "mpv 路径", "MPV_PLAYER_PATH_DESCRIPTION": "设置 MPV 路径。在 macOS 上可以使用 MPV 应用包,例如 /Applications/mpv.app,也可以使用可执行文件路径。", @@ -623,7 +634,9 @@ "NEXT_STATION": "下一个电台", "MUTE": "静音", "UNMUTE": "取消静音", - "VOLUME": "音量" + "VOLUME": "音量", + "RADIO": "电台", + "PAUSED_BADGE": "已暂停" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "按添加日期排序(最新优先)", "SORT_BY_RATING": "按评分排序", "SORT_BY_NAME": "按名称排序(A-Z)", + "SORT_CHANNELS": "排序频道", + "SORT_SERVER_ORDER": "服务器顺序", + "SORT_PLAYLIST_ORDER": "播放列表顺序", "LOAD_MORE": "加载更多", "REMAINING": "剩余", "LOADING": "正在加载频道…", @@ -1132,6 +1148,7 @@ "DURATION": "时长", "CAST": "演员表", "EPISODE_RUN_TIME": "单集时长", + "EPISODE_RUN_TIME_MINUTES": "{{minutes}} 分钟/集", "RATING": "评分", "IMDB_RATING": "IMDb 评分", "KINOPOISK_RATING": "Kinopoisk 评分", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "搜索分类…", "NO_RESULTS": "未找到匹配的分类", "NO_CATEGORIES": "没有可用的分类", - "SAVE": "保存" - } + "SAVE": "保存", + "SAVE_FAILED": "保存分类可见性失败" + }, + "GLOBAL_SEARCH_SUMMARY": "在 {{playlists}} 个播放列表中找到 {{count}} 个项目" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "立即重试" }, "ALL_CATEGORIES": "所有分类", + "ALL_ITEMS": "所有项目", + "UNNAMED_CATEGORY": "未命名分类", + "NO_CATEGORIES": "没有可用的分类", + "UNTITLED": "未命名", "MY_FAVORITES": "我的收藏", "ADD_TO_FAVORITES": "添加到收藏夹", "REMOVE_FROM_FAVORITES": "从收藏中移除", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "取消预告片静音", "PROGRESS_RESET": "进度已重置", "HIDDEN_FROM_CONTINUE_WATCHING": "已从“继续观看”中隐藏" + }, + "SCROLL_LEFT": "向左滚动", + "SCROLL_RIGHT": "向右滚动", + "EPISODE_NAVIGATION": "剧集导航", + "INLINE_PLAYER": "播放器", + "REQUEST_ERRORS": { + "XTREAM": "Xtream 请求失败:{{message}}", + "XTREAM_CONNECTION": "无法连接到 Xtream 服务器", + "STALKER": "Stalker 请求失败:{{message}}(状态 {{status}})", + "NOT_FOUND": "未找到" } }, "INFORMATION": "信息", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "在播放列表中打开", "RAIL_ALL_CHANNELS": "所有频道", "RAIL_GROUPS": "分组", "RAIL_CONTEXT_ACTIONS": "上下文操作", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "输入 PIN 码以显示", "HEADER_LOCK": "立即锁定(家长控制)", "HEADER_UNLOCK": "输入家长 PIN 码" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "正在 {{player}} 中打开…", + "STOP": "停止 {{player}}", + "ERRORS": { + "START_FAILED": "无法启动 {{player}}。请确认已安装该播放器,且设置中的路径正确。", + "PREVIOUS_CLOSING": "上一个播放器仍在关闭。请稍后重试。", + "CLOSED_UNEXPECTEDLY": "{{player}} 意外关闭。", + "STREAM_OPEN_FAILED": "{{player}} 无法打开该流。URL 可能无效,或服务器没有响应。", + "UNSUPPORTED_PROTOCOL": "{{player}} 不支持此流协议。请检查流 URL。", + "CONNECTION_FAILED": "{{player}} 无法连接到流服务器。请检查您的网络连接。", + "ACCESS_DENIED": "拒绝访问。该流可能需要有效的凭据或请求头。", + "STREAM_NOT_FOUND": "未找到流。URL 可能不正确或已过期。", + "TIMED_OUT": "连接超时。服务器没有响应。", + "LAUNCH_FAILED": "无法启动 {{player}}。", + "UNKNOWN": "{{player}} 错误:{{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "视频正在外部播放器窗口中播放。", + "INSTALL_HINT": "请确保您的系统上已正确安装 MPV 或 VLC。", + "INSTALL_INSTRUCTIONS": "安装说明", + "DONT_SHOW_AGAIN": "不再显示" + } } } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index b5921f631..7767e6628 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -63,6 +63,7 @@ "XTREAM_PLAYLIST": "Xtream 播放清單", "STALKER_PORTAL": "Stalker 入口網站", "CHANNELS": "頻道", + "CHANNELS_COUNT": "{{count}} 個頻道", "ADDED": "已新增", "SHOW_DETAILS": "顯示播放清單詳細資訊", "REMOVE": "移除播放清單", @@ -89,6 +90,7 @@ "MAC_ADDRESS": "MAC 位址", "PLAYLIST_EXPORT_SUCCESS": "播放清單已成功匯出。", "PLAYLIST_EXPORT_ERROR": "錯誤:播放清單匯出失敗。", + "EXPORT_PLAYLIST_FAILED": "播放清單匯出失敗。", "COPY_URL": "複製網址至剪貼簿", "URL_COPIED": "已將網址複製至剪貼簿" }, @@ -107,6 +109,10 @@ }, "UPDATED": "已更新", "REFRESH": "重新整理播放清單", + "REFRESH_FETCHING": "正在擷取播放清單…", + "REFRESH_READING_FILE": "正在讀取播放清單檔案…", + "REFRESH_PARSING": "正在解析播放清單…", + "REFRESH_SAVING": "正在儲存播放清單…", "REFRESH_XTREAM": "從遠端重新整理 Xtream 播放清單", "REFRESH_XTREAM_DIALOG": { "TITLE": "重新整理 Xtream 播放清單", @@ -124,6 +130,7 @@ "MY_PLAYLISTS_SUBTITLE": "所有可用清單", "PLAYLIST_UPDATE_SUCCESS": "成功!播放清單已成功更新。", "PLAYLIST_UPDATE_ERROR": "更新播放清單詳細資訊時發生錯誤。", + "PLAYLIST_UPDATE_FAILED": "無法更新播放清單。", "PLAYLIST_UPDATE_FILE_NOT_FOUND": "播放清單重新整理失敗。本機檔案已不存在。請檢查檔案路徑或重新匯入播放清單。", "PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "播放清單重新整理失敗。應用程式無法再存取本機檔案。", "AUTO_REFRESH_UPDATE_SUCCESS": "成功!播放清單已成功更新", @@ -166,6 +173,7 @@ "ERROR_403": "存取被拒(403):伺服器拒絕了此請求。該網址可能需要驗證,或播放清單存取受限。", "ERROR_404": "找不到播放清單(404):該網址未指向有效的播放清單。請檢查網址。", "ERROR_401": "未授權(401):存取此播放清單需要驗證。", + "ERROR_413": "此檔案過大。請使用應用程式的獨立版本或自架版本。", "ERROR_INVALID_TLS": "此播放清單主機的憑證無效。", "ERROR_TLS_HOST_UNKNOWN": "無法判斷播放清單主機。請手動重試。", "TRUST_TLS_HOST": "信任主機", @@ -466,6 +474,8 @@ "IMPORT_DATA": "匯入", "EXPORT_DATA": "匯出", "EXPORTING_DATA": "正在匯出…", + "BACKUP_EXPORTED": "播放清單備份已匯出。", + "BACKUP_EXPORT_FAILED": "播放清單備份匯出失敗。", "REMOVE_ALL": "刪除所有播放清單和播放清單資料", "REMOVE_ALL_BUTTON": "全部刪除", "REMOVE_ALL_DESCRIPTION": "刪除應用程式中儲存的每個 M3U、Xtream 和 Stalker 播放清單。", @@ -483,6 +493,7 @@ "CONFIRM": "全部刪除" }, "IMPORT_ERROR": "匯入錯誤,請嘗試另外一個檔案。", + "BACKUP_IMPORT_SUMMARY": "備份匯入完成:已匯入 {{imported}} 個,已合併 {{merged}} 個,已略過 {{skipped}} 個,{{failed}} 個失敗。", "PLAYLISTS_REMOVED": "已移除所有播放清單和播放清單資料。", "MPV_PLAYER_PATH_LABEL": "mpv 路徑", "MPV_PLAYER_PATH_DESCRIPTION": "設定 MPV 路徑。在 macOS 上可以使用 MPV App bundle,例如 /Applications/mpv.app,也可以使用可執行檔路徑。", @@ -623,7 +634,9 @@ "NEXT_STATION": "下一個電台", "MUTE": "靜音", "UNMUTE": "取消靜音", - "VOLUME": "音量" + "VOLUME": "音量", + "RADIO": "廣播", + "PAUSED_BADGE": "已暫停" }, "EMBEDDED_MPV": { "PLAYER": { @@ -850,6 +863,9 @@ "SORT_BY_DATE": "依新增日期排序(最新優先)", "SORT_BY_RATING": "依評分排序", "SORT_BY_NAME": "依名稱排序(A-Z)", + "SORT_CHANNELS": "排序頻道", + "SORT_SERVER_ORDER": "伺服器順序", + "SORT_PLAYLIST_ORDER": "播放清單順序", "LOAD_MORE": "載入更多", "REMAINING": "剩餘", "LOADING": "正在載入頻道...", @@ -1132,6 +1148,7 @@ "DURATION": "片長", "CAST": "演員陣容", "EPISODE_RUN_TIME": "集數時長", + "EPISODE_RUN_TIME_MINUTES": "每集 {{minutes}} 分鐘", "RATING": "評分", "IMDB_RATING": "IMDb 評分", "KINOPOISK_RATING": "Kinopoisk 評分", @@ -1213,8 +1230,10 @@ "SEARCH_PLACEHOLDER": "搜尋類別...", "NO_RESULTS": "找不到符合的類別", "NO_CATEGORIES": "沒有可用的類別", - "SAVE": "儲存" - } + "SAVE": "儲存", + "SAVE_FAILED": "無法儲存類別可見性" + }, + "GLOBAL_SEARCH_SUMMARY": "在 {{playlists}} 個播放清單中找到 {{count}} 個項目" }, "PORTALS": { "REQUESTS_PAUSED": { @@ -1223,6 +1242,10 @@ "RETRY": "立即重試" }, "ALL_CATEGORIES": "所有類別", + "ALL_ITEMS": "所有項目", + "UNNAMED_CATEGORY": "未命名類別", + "NO_CATEGORIES": "沒有可用的類別", + "UNTITLED": "未命名", "MY_FAVORITES": "我的最愛", "ADD_TO_FAVORITES": "新增至最愛", "REMOVE_FROM_FAVORITES": "從最愛中移除", @@ -1422,6 +1445,16 @@ "TRAILER_UNMUTE": "取消預告片靜音", "PROGRESS_RESET": "進度已重設", "HIDDEN_FROM_CONTINUE_WATCHING": "已從「繼續觀看」隱藏" + }, + "SCROLL_LEFT": "向左捲動", + "SCROLL_RIGHT": "向右捲動", + "EPISODE_NAVIGATION": "集數導覽", + "INLINE_PLAYER": "播放器", + "REQUEST_ERRORS": { + "XTREAM": "Xtream 請求失敗:{{message}}", + "XTREAM_CONNECTION": "無法連線至 Xtream 伺服器", + "STALKER": "Stalker 請求失敗:{{message}}(狀態 {{status}})", + "NOT_FOUND": "找不到" } }, "INFORMATION": "資訊", @@ -1673,6 +1706,7 @@ "EXTERNAL_PLAYBACK_PLAYING": "Playing", "EXTERNAL_PLAYBACK_FAILED": "External player error", "EXTERNAL_PLAYBACK_CLOSED": "Closed", + "EXTERNAL_PLAYBACK_OPEN_IN_PLAYLIST": "在播放清單中開啟", "RAIL_ALL_CHANNELS": "所有頻道", "RAIL_GROUPS": "群組", "RAIL_CONTEXT_ACTIONS": "區段操作", @@ -2015,5 +2049,28 @@ "UNLOCK_TO_SHOW": "輸入 PIN 碼以顯示", "HEADER_LOCK": "立即鎖定(家長監護)", "HEADER_UNLOCK": "輸入家長 PIN 碼" + }, + "EXTERNAL_PLAYER": { + "OPENING_IN": "正在 {{player}} 中開啟…", + "STOP": "停止 {{player}}", + "ERRORS": { + "START_FAILED": "無法啟動 {{player}}。請確認已安裝,且設定中的路徑正確。", + "PREVIOUS_CLOSING": "上一個播放器仍在關閉中。請稍後再試。", + "CLOSED_UNEXPECTEDLY": "{{player}} 意外關閉。", + "STREAM_OPEN_FAILED": "{{player}} 無法開啟串流。URL 可能無效,或伺服器沒有回應。", + "UNSUPPORTED_PROTOCOL": "{{player}} 不支援此串流通訊協定。請檢查串流 URL。", + "CONNECTION_FAILED": "{{player}} 無法連線至串流伺服器。請檢查您的網路連線。", + "ACCESS_DENIED": "存取被拒。此串流可能需要有效的憑證或請求標頭。", + "STREAM_NOT_FOUND": "找不到串流。URL 可能不正確或已過期。", + "TIMED_OUT": "連線逾時。伺服器沒有回應。", + "LAUNCH_FAILED": "無法啟動 {{player}}。", + "UNKNOWN": "{{player}} 錯誤:{{error}}" + }, + "INFO_DIALOG": { + "PLAYING": "影片正在外部播放器視窗中播放。", + "INSTALL_HINT": "請確認您的系統已正確安裝 MPV 或 VLC。", + "INSTALL_INSTRUCTIONS": "安裝說明", + "DONT_SHOW_AGAIN": "不再顯示" + } } } diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 52d812b0a..cf61ef3f3 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -258,7 +258,8 @@ pnpm run coverage:e2e:v8:web ```bash pnpm run i18n:validate # checker unit tests, then the check (CI) -pnpm run i18n:check # the check only +pnpm run i18n:check # the check only: locale drift, then key usage +pnpm run i18n:usage # key usage only pnpm run i18n:baseline:update # deliberate: rewrite the English-identical baseline ``` @@ -281,8 +282,20 @@ that is legitimately identical in that language; review the diff, and never run it in CI. A new locale starts with no baseline entries, so it has to record its legitimate identical values the same way. `node tools/i18n/check-drift.mjs --fail-on-identical` ignores the baseline for -a full translation audit. CI runs `pnpm run i18n:validate` in the unit test -job. +a full translation audit. + +The usage check (`tools/i18n/check-usage.mjs`) fails on a key that renderer +code uses but `en.json` lacks, because ngx-translate shows such a key raw. It +reads production `.ts` and `.html` files under `apps/web/src`, +`apps/remote-control-web/src` and `libs`, without comments, and treats as a +use: a quoted key before `| translate`, or a ternary or fallback branch of a +parenthesised operand before it; the first argument of `instant`, `get` +or `stream` on a translate service, or of a function named like `translate*`, +`marker` or `t`; and a dotted upper-case literal in an `en.json` namespace, +which may also name a group of keys that code completes. The static prefix of +a template literal such as `` `EPG.DIALOG.${name}` `` must name a group. Keys +built entirely at runtime are not checked. CI runs `pnpm run i18n:validate` in +the unit test job. ## Performance diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts index 8ccf73df2..5da4cc381 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -10,7 +10,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { DialogService } from '@iptvnator/ui/components'; @@ -216,6 +216,29 @@ describe('PlaylistSwitcherComponent', () => { jest.restoreAllMocks(); }); + it('labels each playlist row with translated text', async () => { + await createComponent(); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + HOME: { PLAYLISTS: { CHANNELS_COUNT: '{{count}} channels' } }, + WORKSPACE: { + SHELL: { + STALKER_PORTAL: 'Stalker Portal', + XTREAM_CODE: 'Xtream Code', + }, + }, + }); + translate.use('en'); + + expect(component.getPlaylistMetaLabel(m3uPlaylist)).toBe('24 channels'); + expect(component.getPlaylistMetaLabel(stalkerPlaylist)).toBe( + 'Stalker Portal' + ); + expect(component.getPlaylistMetaLabel(xtreamPlaylist)).toBe( + 'Xtream Code' + ); + }); + it('reads persisted search and type filters on startup and filters playlists accordingly', async () => { localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room'); localStorage.setItem( diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index da0bf7fa5..c016a87c0 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -363,35 +363,16 @@ export class PlaylistSwitcherComponent { return getPlaylistSourceIcon(playlist); } - getPlaylistTypeLabel(playlist: PlaylistMeta): string { - if (playlist.macAddress) { - return 'Stalker Portal'; - } - if (playlist.serverUrl) { - return 'Xtream Code'; - } - return `${playlist.count} channels`; - } - getPlaylistMetaLabel(playlist: PlaylistMeta): string { - const count = playlist.count ?? 0; - const channelsLabel = this.translate.instant( - 'HOME.PLAYLISTS.CHANNELS_COUNT', - { count } - ); - const fallback = `${count} channels`; - const countLabel = - channelsLabel && channelsLabel !== 'HOME.PLAYLISTS.CHANNELS_COUNT' - ? channelsLabel - : fallback; - if (playlist.macAddress) { - return 'Stalker Portal'; + return this.translate.instant('WORKSPACE.SHELL.STALKER_PORTAL'); } if (playlist.serverUrl) { - return 'Xtream Code'; + return this.translate.instant('WORKSPACE.SHELL.XTREAM_CODE'); } - return countLabel; + return this.translate.instant('HOME.PLAYLISTS.CHANNELS_COUNT', { + count: playlist.count ?? 0, + }); } getStatusClass(playlistId: string): string { diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index 743881f77..fc74e015e 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -11,10 +11,14 @@

- {{ selectedCategoryTitle() }} + {{ + selectedCategoryTitle() ?? + ('PORTALS.ALL_ITEMS' | translate) + }}

- {{ categoryItemSubtitle() }} + @let subtitle = categoryItemSubtitle(); + {{ subtitle.key | translate: subtitle.params }}
@if (hasRefineControls()) { diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 19bc99759..9943c22fa 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -84,7 +84,7 @@ describe('CategoryContentViewComponent', () => { contentType: signal('vod'), selectedCategory: signal({ id: 1 }), paginatedContent: signal([]), - selectedCategoryTitle: signal('Movies'), + selectedCategoryTitle: signal('Movies'), categoryItemCount, selectedItem, hasMore, @@ -112,6 +112,7 @@ describe('CategoryContentViewComponent', () => { beforeEach(async () => { routeReady.set(true); playlist.set(null); + catalog.selectedCategoryTitle.set('Movies'); window.history.replaceState({}, '', window.location.href); catalog.provider = 'xtream'; selectedItem.set(null); @@ -231,6 +232,37 @@ describe('CategoryContentViewComponent', () => { ); }); + it('titles the every-item grid with the translated label', () => { + catalog.selectedCategoryTitle.set(null); + fixture.detectChanges(); + + const title = fixture.nativeElement.querySelector( + '.category-title' + ) as HTMLElement | null; + + expect(title?.textContent?.trim()).toBe('PORTALS.ALL_ITEMS'); + }); + + it('counts items with the translated singular and plural keys', () => { + isPaginatedContentLoading.set(false); + categoryItemCount.set(1); + fixture.detectChanges(); + + const subtitle = () => + ( + fixture.nativeElement.querySelector( + '.category-subtitle' + ) as HTMLElement | null + )?.textContent?.trim(); + + expect(subtitle()).toBe('WORKSPACE.CONTEXT.ITEM_COUNT_ONE'); + + categoryItemCount.set(3); + fixture.detectChanges(); + + expect(subtitle()).toBe('WORKSPACE.CONTEXT.ITEM_COUNT_OTHER'); + }); + it('forwards query-param search updates to the catalog facade when supported', () => { fixture.detectChanges(); catalog.setSearchQuery.mockClear(); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index c6cf9375c..66a409f46 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -19,7 +19,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { GridListComponent, InfiniteScrollDirective, @@ -73,7 +73,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { private readonly destroyRef = inject(DestroyRef); private readonly hostElement = inject(ElementRef); private readonly router = inject(Router); - private readonly translate = inject(TranslateService); private readonly providerOnlyStalkerItemId = signal(null); private readonly catalog = inject( PORTAL_CATALOG_FACADE @@ -100,15 +99,20 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { this.catalog.provider === 'xtream' && this.isPaginatedContentLoading() ); + /** Key and params; the template pipe re-renders once translations load. */ readonly categoryItemSubtitle = computed(() => { if (this.isXtreamLoadingSubtitle()) { - return this.translate.instant( - 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING' - ); + return { key: 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING', params: {} }; } - const itemCount = this.categoryItemCount(); - return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`; + const count = this.categoryItemCount(); + return { + key: + count === 1 + ? 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' + : 'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER', + params: { count }, + }; }); readonly canSortContent = computed(() => this.contentSortMode() !== null); readonly supportsRatingSort = computed( diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html index c9be7b7a6..da30ba425 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html @@ -15,7 +15,7 @@ {{ item.category_name || $any(item).name || - 'No category name' + ('PORTALS.UNNAMED_CATEGORY' | translate) }} @if (showCounts()) { @@ -42,7 +42,7 @@ } @else {
chevron_left @@ -48,7 +48,7 @@ class="rail__chev rail__chev--right" [class.rail__chev--visible]="canScrollRight()" (click)="scrollBy(1)" - aria-label="Scroll right" + [attr.aria-label]="'PORTALS.SCROLL_RIGHT' | translate" tabindex="-1" > chevron_right diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts index c24e8a2be..fb81e2adc 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts @@ -253,7 +253,7 @@ describe('GridListComponent', () => { fixture.detectChanges(); const title = fixture.debugElement.query(By.css('.title')); - expect(title.nativeElement.textContent.trim()).toBe('No name'); + expect(title.nativeElement.textContent.trim()).toBe('PORTALS.UNTITLED'); }); }); diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index 28ac27fd6..7f5758c02 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -108,7 +108,8 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { } @else { @for (item of items(); track $index) { @let i = $any(item); - @let title = channelTitle(i); + @let title = + channelTitle(i) || ('PORTALS.UNTITLED' | translate); diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.html b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.html index 54d3f0192..a8c5b6f90 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.html +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.html @@ -5,13 +5,10 @@ > @if (showIllustration()) { @if (viewType() === 'ERROR') { - Something went wrong + } @else if (viewType() === 'EMPTY_CATEGORY') { empty category diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 0ff2dbcb2..8c3354c08 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -41,7 +41,7 @@ mat-icon-button (click)="onClose()" class="radio-close-btn" - aria-label="Close player" + [attr.aria-label]="'PORTALS.CLOSE_PLAYER' | translate" > close diff --git a/libs/portal/shared/util/src/lib/channel-sort.ts b/libs/portal/shared/util/src/lib/channel-sort.ts index 6f86c26ea..54c86b18b 100644 --- a/libs/portal/shared/util/src/lib/channel-sort.ts +++ b/libs/portal/shared/util/src/lib/channel-sort.ts @@ -28,18 +28,19 @@ export function persistPortalChannelSortMode( localStorage.setItem(storageKey, mode); } -export function getPortalChannelSortModeLabel( +/** Translation key of the sort mode's menu label. */ +export function getPortalChannelSortModeLabelKey( mode: PortalChannelSortMode ): string { if (mode === 'name-asc') { - return 'Name A-Z'; + return 'WORKSPACE.SORT_NAME_ASC'; } if (mode === 'name-desc') { - return 'Name Z-A'; + return 'WORKSPACE.SORT_NAME_DESC'; } - return 'Server Order'; + return 'CHANNELS.SORT_SERVER_ORDER'; } export function sortPortalChannelItems( diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts index 7483e417a..507b6f9c4 100644 --- a/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts @@ -49,7 +49,10 @@ describe('createExternalPlaybackButtonState', () => { expect(api.matchedSession()).not.toBeNull(); expect(api.buttonState()).toBe('stop'); expect(api.primaryIcon()).toBe('stop_circle'); - expect(api.primaryLabel()).toBe('Stop MPV'); + expect(api.primaryLabel()).toEqual({ + key: 'EXTERNAL_PLAYER.STOP', + params: { player: 'MPV' }, + }); expect(api.isStopAction()).toBe(true); }); @@ -119,7 +122,10 @@ describe('createExternalPlaybackButtonState', () => { expect(api.matchedSession()).not.toBeNull(); expect(api.buttonState()).toBe('stop'); - expect(api.primaryLabel()).toBe('Stop MPV'); + expect(api.primaryLabel()).toEqual({ + key: 'EXTERNAL_PLAYER.STOP', + params: { player: 'MPV' }, + }); expect(api.isStopAction()).toBe(true); }); @@ -128,7 +134,10 @@ describe('createExternalPlaybackButtonState', () => { expect(api.buttonState()).toBe('launching'); expect(api.primaryIcon()).toBe('hourglass_top'); - expect(api.primaryLabel()).toBe('Opening in MPV...'); + expect(api.primaryLabel()).toEqual({ + key: 'EXTERNAL_PLAYER.OPENING_IN', + params: { player: 'MPV' }, + }); expect(api.isLaunchPending()).toBe(true); expect(api.isStopAction()).toBe(false); }); diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.ts index c28d45a4a..81cbd1036 100644 --- a/libs/portal/shared/util/src/lib/external-playback-button-state.ts +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.ts @@ -15,6 +15,12 @@ import type { export type ExternalPlaybackButtonState = 'idle' | 'launching' | 'stop'; +/** Translation key and params for the primary button's label. */ +export interface ExternalPlaybackButtonLabel { + readonly key: string; + readonly params: { readonly player: string }; +} + export interface ExternalPlaybackButtonStateConfig { /** The currently active external player session, if any. */ session: Signal; @@ -41,7 +47,7 @@ export interface ExternalPlaybackButtonStateApi { * playing a different movie must not turn this page's Play into Stop. */ matchedSession: Signal; - primaryLabel: Signal; + primaryLabel: Signal; primaryIcon: Signal; isLaunchPending: Signal; isStopAction: Signal; @@ -91,20 +97,20 @@ export function createExternalPlaybackButtonState( return owned ? session : null; }); - const primaryLabel = computed(() => { + const primaryLabel = computed(() => { const session = matchedSession(); if (!session) { return null; } - const player = session.player.toUpperCase(); + const params = { player: session.player.toUpperCase() }; switch (session.status) { case 'launching': - return `Opening in ${player}...`; + return { key: 'EXTERNAL_PLAYER.OPENING_IN', params }; case 'opened': case 'playing': case 'error': - return `Stop ${player}`; + return { key: 'EXTERNAL_PLAYER.STOP', params }; default: return null; } diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index de5cacd3e..dfb041d12 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -45,7 +45,11 @@ export interface PortalCatalogFacade< readonly paginatedContent: Signal; readonly selectedItem: Signal; readonly isPaginatedContentLoading: Signal; - readonly selectedCategoryTitle: Signal; + /** + * Null while no provider category is selected and the catalog lists every + * item; the view shows its own translated "All items" title then. + */ + readonly selectedCategoryTitle: Signal; readonly categoryItemCount: Signal; readonly contentSortMode: Signal; readonly playlist: Signal; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index e1f013eca..90c8f5df2 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -1096,10 +1096,11 @@ export function withStalkerContent() { ), getSelectedCategory: computed(() => { const categoryId = storeContext.selectedCategoryId(); + // Every item: the view supplies the translated title. if (!categoryId) { return { id: 0, - category_name: 'All Items', + category_name: '', type: storeContext.selectedContentType(), }; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 7a9525784..aa01c458c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -122,6 +122,24 @@ describe('StalkerCatalogFacadeService', () => { }); }); + it('leaves the every-item title to the view while no category is selected', () => { + const service = TestBed.inject(StalkerCatalogFacadeService); + const categoryId = stalkerStoreMock['selectedCategoryId'] as ReturnType< + typeof signal + >; + ( + stalkerStoreMock['getSelectedCategory'] as ReturnType< + typeof signal | null> + > + ).set({ category_id: '5', category_name: 'News' }); + + expect(service.selectedCategoryTitle()).toBe('News'); + + categoryId.set(null); + + expect(service.selectedCategoryTitle()).toBeNull(); + }); + it('delegates category search query updates to the Stalker store', () => { const service = TestBed.inject(StalkerCatalogFacadeService); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index aaf96dcd6..9bc7da421 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -84,6 +84,9 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< */ private readonly savedScrollPositions = new Map(); readonly selectedCategoryTitle = computed(() => { + if (!this.stalkerStore.selectedCategoryId()) { + return null; + } const category = this.selectedCategory(); const fromCategory = category ? String(category.category_name ?? '') diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index ec60c8da1..b2658afa5 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -427,10 +427,11 @@ export function withSelection() { */ getSelectedCategory: computed(() => { const categoryId = store.selectedCategoryId(); + // Every item: the view supplies the translated title. if (!categoryId) { return { id: 0, - name: 'All Items', + name: '', type: store.selectedContentType(), }; } diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts index de76d3c68..170f0c5ad 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts @@ -289,7 +289,7 @@ describe('CategoryManagementDialogComponent', () => { expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled(); expect(snackBar.open).toHaveBeenCalledWith( - 'Failed to save category visibility', + 'XTREAM.CATEGORY_MANAGEMENT.SAVE_FAILED', 'CLOSE', { duration: 3000 } ); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index 3c268dde0..f0284833b 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -240,8 +240,11 @@ export class CategoryManagementDialogComponent implements OnInit { await this.saveVisibility(categories); } catch (error) { this.logger.error('Error saving category visibility', error); - // No translation key exists for this message yet. - this.showSaveFailure('Failed to save category visibility'); + this.showSaveFailure( + this.translate.instant( + 'XTREAM.CATEGORY_MANAGEMENT.SAVE_FAILED' + ) + ); return; } if (!(await this.saveLocks(categories))) { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index a8ed17f15..74bda23c8 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -40,7 +40,13 @@ (click)="livePanels.openCategoriesPopover(categoryDropdown)" > - {{ selectedCategoryInfo()?.name ?? 'All Items' }} + {{ + selectedCategoryInfo()?.name ?? + ((selectedCategoryInfo() + ? 'HOME.PLAYLISTS.CHANNELS' + : 'PORTALS.ALL_ITEMS' + ) | translate) + }} @if (selectedCategoryInfo()?.count) { {{ @@ -54,7 +60,13 @@ } @else {

- {{ selectedCategoryInfo()?.name ?? 'All Items' }} + {{ + selectedCategoryInfo()?.name ?? + ((selectedCategoryInfo() + ? 'HOME.PLAYLISTS.CHANNELS' + : 'PORTALS.ALL_ITEMS' + ) | translate) + }}

@if (selectedCategoryInfo()?.count) { {{ @@ -79,8 +91,11 @@ @@ -98,7 +113,7 @@ mat-menu-item (click)="setLiveChannelSortMode('server')" > - Server Order + {{ 'CHANNELS.SORT_SERVER_ORDER' | translate }} @if (liveChannelSortMode() === 'server') { check } @@ -107,7 +122,7 @@ mat-menu-item (click)="setLiveChannelSortMode('name-asc')" > - Name A-Z + {{ 'WORKSPACE.SORT_NAME_ASC' | translate }} @if (liveChannelSortMode() === 'name-asc') { check } @@ -116,7 +131,7 @@ mat-menu-item (click)="setLiveChannelSortMode('name-desc')" > - Name Z-A + {{ 'WORKSPACE.SORT_NAME_DESC' | translate }} @if (liveChannelSortMode() === 'name-desc') { check } @@ -189,9 +204,16 @@
-

All Items

+

+ {{ 'PORTALS.ALL_ITEMS' | translate }} +

- {{ liveRootSubtitle() }} + {{ + (liveRootItemCount() === 1 + ? 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' + : 'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER' + ) | translate: { count: liveRootItemCount() } + }}
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index e96576305..c65ae9ba5 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -494,11 +494,11 @@ describe('LiveStreamLayoutComponent', () => { expect(grid.componentInstance.type()).toBe('live'); expect( fixture.nativeElement.querySelector('.category-title').textContent - ).toContain('All Items'); + ).toContain('PORTALS.ALL_ITEMS'); expect( fixture.nativeElement.querySelector('.category-subtitle') .textContent - ).toContain('3 channels'); + ).toContain('WORKSPACE.CONTEXT.ITEM_COUNT_OTHER'); expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull(); expect( fixture.debugElement.query( diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index b4a3e002b..25dcdb3b6 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -39,7 +39,7 @@ import { createLivePanelsController, PORTAL_PLAYER, PortalChannelSortMode, - getPortalChannelSortModeLabel, + getPortalChannelSortModeLabelKey, getAdjacentChannelItem, getChannelItemByNumber, isTypingInInput, @@ -383,8 +383,8 @@ export class LiveStreamLayoutComponent readonly showReturnToLive = computed( () => this.activeCatchupProgram() !== null ); - readonly liveChannelSortLabel = computed(() => - getPortalChannelSortModeLabel(this.liveChannelSortMode()) + readonly liveChannelSortLabelKey = computed(() => + getPortalChannelSortModeLabelKey(this.liveChannelSortMode()) ); readonly liveRootItems = computed( () => @@ -396,10 +396,6 @@ export class LiveStreamLayoutComponent readonly liveRootItemCount = computed( () => this.xtreamStore.selectItemsFromSelectedCategory().length ); - readonly liveRootSubtitle = computed(() => { - const count = this.liveRootItemCount(); - return `${count} ${count === 1 ? 'channel' : 'channels'}`; - }); readonly liveRootHasMore = this.xtreamStore.hasMoreContent; readonly selectedCategoryInfo = computed(() => { @@ -415,8 +411,9 @@ export class LiveStreamLayoutComponent ); const count = this.categoryItemCounts()?.get(categoryId) ?? 0; + // A null name falls back to a translated label in the template. return { - name: category?.category_name ?? category?.name ?? 'Channels', + name: category?.category_name ?? category?.name ?? null, count, }; }); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts index cfba09dc5..26f3b060c 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts @@ -328,7 +328,7 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { it('leaves focus alone when a control still owns it', async () => { fixture.detectChanges(); const sort = query( - '.sidebar-header [aria-label="Sort channels"]' + '.sidebar-header [aria-label="CHANNELS.SORT_CHANNELS"]' ); sort?.focus(); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html index d4443bf8e..11630c282 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html @@ -105,8 +105,15 @@

- Found {{ xtreamStore.searchResults().length }} items - across {{ groupedResults().length }} playlists + {{ + 'XTREAM.GLOBAL_SEARCH_SUMMARY' + | translate + : { + count: xtreamStore.searchResults() + .length, + playlists: groupedResults().length, + } + }}

@for (group of groupedResults(); track group.playlistId) {
@@ -114,7 +121,14 @@ playlist_play {{ group.playlistName }} ({{ group.items.length }} items)({{ + (group.items.length === 1 + ? 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' + : 'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER' + ) + | translate + : { count: group.items.length } + }})
diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 937b475e6..09e8f3214 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -71,9 +71,10 @@ } @if (info.episode_run_time) { - {{ info.episode_run_time }} min/ep + {{ + 'XTREAM.EPISODE_RUN_TIME_MINUTES' + | translate: { minutes: info.episode_run_time } + }} } @for (country of hero.countryChips(); track country.label) { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts index 6ef9f4c61..7fd681a21 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-hero.presenter.ts @@ -5,6 +5,7 @@ import { formatRemainingLabel, parseDurationSeconds, playbackProgressPercent, + type ExternalPlaybackButtonLabel, shortCountryList, shortCountryName, type RemainingTimeLabel, @@ -51,7 +52,7 @@ interface VodDetailsHeroBindings { readonly position: Signal; readonly hasPlaybackPosition: Signal; readonly isOfflinePrimary: Signal; - readonly externalLabel: Signal; + readonly externalLabel: Signal; readonly externalIcon: Signal; readonly externalState: Signal; readonly formatPosition: () => string; @@ -140,7 +141,15 @@ export class VodDetailsHeroPresenter { : (bindings?.externalIcon() ?? 'play_arrow'); const externalLabel = bindings?.externalLabel(); if (externalLabel) { - return { label: externalLabel, meta: null, icon, state }; + return { + label: this.translate.instant( + externalLabel.key, + externalLabel.params + ), + meta: null, + icon, + state, + }; } if (bindings?.isOfflinePrimary()) { return { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index c25e35777..0e137f037 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -790,7 +790,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { '[data-testid="vod-primary-action"]' ); expect(primary?.disabled).toBe(true); - expect(primary?.textContent).toContain('Opening in MPV...'); + expect(primary?.textContent).toContain('EXTERNAL_PLAYER.OPENING_IN'); expect( Array.from(host.querySelectorAll('button')).some((button) => button.textContent?.includes('XTREAM.RESTART') diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts index 3dcf8ef66..5b7bbd245 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts @@ -121,6 +121,15 @@ describe('XtreamCatalogFacadeService', () => { service = TestBed.inject(XtreamCatalogFacadeService); }); + it('titles a selected category and leaves the every-item title to the view', () => { + expect(service.selectedCategoryTitle()).toBe('Movies'); + + selectedCategoryId.set(null); + selectedCategory.set({ id: 0, name: '' }); + + expect(service.selectedCategoryTitle()).toBeNull(); + }); + it('delegates category search to the Xtream store', () => { service.setSearchQuery('matrix'); diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 7236c0a3c..ba8894e3b 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -44,6 +44,9 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< readonly isPaginatedContentLoading = this.xtreamStore.isPaginatedContentLoading; readonly selectedCategoryTitle = computed(() => { + if (!this.xtreamStore.selectedCategoryId()) { + return null; + } const category = this.selectedCategory(); return String(category?.['name'] ?? category?.['title'] ?? ''); }); diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index ec330e70e..c902e087c 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -19,7 +19,10 @@ import { ContentMetadataPatch } from './content-metadata.interface'; import { DownloadMetadataSnapshot } from './download-metadata.interface'; import { EpgChannelMetadata } from './epg-channel-metadata.model'; import { EpgProgram } from './epg-program.model'; -import { ExternalPlayerSession } from './external-player-session.interface'; +import { + ExternalPlayerErrorCode, + ExternalPlayerSession, +} from './external-player-session.interface'; import { GlobalSearchPaginationOptions, GlobalSearchResult, @@ -603,10 +606,13 @@ export interface ElectronBridgeRemoteControlStatus { muted?: boolean; } +/** Payload of the main process `player-error` event. */ export interface ElectronBridgePlayerError { - player: string; + player: 'MPV' | 'VLC'; + /** Null when no code describes the output; see `error`. */ + code: ExternalPlayerErrorCode | null; + /** Raw player output, for logs and the unknown-error message. */ error: string; - originalError: string; } export interface ElectronBridgePlaybackPositionInput extends Omit< diff --git a/libs/shared/interfaces/src/lib/external-player-session.interface.spec.ts b/libs/shared/interfaces/src/lib/external-player-session.interface.spec.ts new file mode 100644 index 000000000..6f529fbc5 --- /dev/null +++ b/libs/shared/interfaces/src/lib/external-player-session.interface.spec.ts @@ -0,0 +1,64 @@ +import { + EXTERNAL_PLAYER_ERROR_KEYS, + readExternalPlayerErrorCode, + stripExternalPlayerErrorTag, + tagExternalPlayerError, +} from './external-player-session.interface'; + +describe('external player error codes', () => { + it('reads the code back from a tagged message', () => { + const message = tagExternalPlayerError( + 'start-failed', + 'Failed to start MPV player: spawn mpv ENOENT' + ); + + expect(readExternalPlayerErrorCode(new Error(message))).toBe( + 'start-failed' + ); + expect(readExternalPlayerErrorCode(message)).toBe('start-failed'); + }); + + it('survives the prefix Electron adds to a rejected IPC call', () => { + const rejected = new Error( + `Error invoking remote method 'OPEN_MPV_PLAYER': Error: ${tagExternalPlayerError( + 'previous-closing', + 'Cannot launch player' + )}` + ); + + expect(readExternalPlayerErrorCode(rejected)).toBe('previous-closing'); + }); + + it('strips the tag and leaves untagged messages alone', () => { + expect( + stripExternalPlayerErrorTag( + tagExternalPlayerError('start-failed', 'Failed to start MPV') + ) + ).toBe('Failed to start MPV'); + expect(stripExternalPlayerErrorTag('MPV IPC command timed out')).toBe( + 'MPV IPC command timed out' + ); + }); + + it('returns null for untagged messages and unknown codes', () => { + expect(readExternalPlayerErrorCode(new Error('boom'))).toBeNull(); + expect( + readExternalPlayerErrorCode( + '[iptvnator:external-player:not-a-code] boom' + ) + ).toBeNull(); + expect( + readExternalPlayerErrorCode( + '[iptvnator:external-player:constructor]' + ) + ).toBeNull(); + expect(readExternalPlayerErrorCode(null)).toBeNull(); + expect(readExternalPlayerErrorCode({ message: 42 })).toBeNull(); + }); + + it('maps every code to a key in the EXTERNAL_PLAYER.ERRORS group', () => { + for (const key of Object.values(EXTERNAL_PLAYER_ERROR_KEYS)) { + expect(key).toMatch(/^EXTERNAL_PLAYER\.ERRORS\.[A-Z_]+$/); + } + }); +}); diff --git a/libs/shared/interfaces/src/lib/external-player-session.interface.ts b/libs/shared/interfaces/src/lib/external-player-session.interface.ts index e676358f1..d7c17c436 100644 --- a/libs/shared/interfaces/src/lib/external-player-session.interface.ts +++ b/libs/shared/interfaces/src/lib/external-player-session.interface.ts @@ -5,6 +5,83 @@ export type ExternalPlayerName = 'mpv' | 'vlc'; export type ExternalPlayerSessionStatus = 'launching' | 'opened' | 'playing' | 'error' | 'closed'; +/** + * Why an external player failed. The main process classifies the failure and + * the renderer translates it; the raw player output stays in `error`. + */ +export type ExternalPlayerErrorCode = + | 'start-failed' + | 'previous-closing' + | 'closed-unexpectedly' + | 'stream-open-failed' + | 'unsupported-protocol' + | 'connection-failed' + | 'access-denied' + | 'stream-not-found' + | 'timed-out'; + +/** Translation keys for each code; a message may use `{{player}}`. */ +export const EXTERNAL_PLAYER_ERROR_KEYS: Readonly< + Record +> = { + 'start-failed': 'EXTERNAL_PLAYER.ERRORS.START_FAILED', + 'previous-closing': 'EXTERNAL_PLAYER.ERRORS.PREVIOUS_CLOSING', + 'closed-unexpectedly': 'EXTERNAL_PLAYER.ERRORS.CLOSED_UNEXPECTEDLY', + 'stream-open-failed': 'EXTERNAL_PLAYER.ERRORS.STREAM_OPEN_FAILED', + 'unsupported-protocol': 'EXTERNAL_PLAYER.ERRORS.UNSUPPORTED_PROTOCOL', + 'connection-failed': 'EXTERNAL_PLAYER.ERRORS.CONNECTION_FAILED', + 'access-denied': 'EXTERNAL_PLAYER.ERRORS.ACCESS_DENIED', + 'stream-not-found': 'EXTERNAL_PLAYER.ERRORS.STREAM_NOT_FOUND', + 'timed-out': 'EXTERNAL_PLAYER.ERRORS.TIMED_OUT', +}; + +/** For output no code describes; takes `{{player}}` and the raw `{{error}}`. */ +export const EXTERNAL_PLAYER_UNKNOWN_ERROR_KEY = + 'EXTERNAL_PLAYER.ERRORS.UNKNOWN'; + +/** For a rejected launch that carries no code; takes `{{player}}`. */ +export const EXTERNAL_PLAYER_LAUNCH_FAILED_KEY = + 'EXTERNAL_PLAYER.ERRORS.LAUNCH_FAILED'; + +const EXTERNAL_PLAYER_ERROR_TAG = /\[iptvnator:external-player:([a-z-]+)\]/; + +/** + * Prefixes an error message with its code. A rejected IPC call reaches the + * renderer as a message only, so the code has to travel inside it. + */ +export function tagExternalPlayerError( + code: ExternalPlayerErrorCode, + message: string +): string { + return `[iptvnator:external-player:${code}] ${message}`; +} + +/** Removes the code tag so the message can be shown or stored as detail. */ +export function stripExternalPlayerErrorTag(message: string): string { + return message.replace( + new RegExp(`${EXTERNAL_PLAYER_ERROR_TAG.source}\\s*`, 'g'), + '' + ); +} + +/** Reads the code `tagExternalPlayerError` put into an error message. */ +export function readExternalPlayerErrorCode( + error: unknown +): ExternalPlayerErrorCode | null { + const message = + typeof error === 'string' + ? error + : (error as { message?: unknown } | null)?.message; + const code = + typeof message === 'string' + ? EXTERNAL_PLAYER_ERROR_TAG.exec(message)?.[1] + : undefined; + return code && + Object.prototype.hasOwnProperty.call(EXTERNAL_PLAYER_ERROR_KEYS, code) + ? (code as ExternalPlayerErrorCode) + : null; +} + export interface ExternalPlayerSession { id: string; player: ExternalPlayerName; @@ -15,7 +92,9 @@ export interface ExternalPlayerSession { contentInfo?: PlayerContentInfo; startedAt: string; updatedAt: string; + /** Raw failure detail; the dock translates `errorCode` instead. */ error?: string; + errorCode?: ExternalPlayerErrorCode; canClose: boolean; /** Exact failed replacement displaced by this restored live session. */ restoredFromSessionId?: string; diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 63087f210..2cfb7c82e 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -7,8 +7,11 @@ mat-icon-button class="all-channels-sort-button" [matMenuTriggerFor]="channelSortMenu" - [matTooltip]="'Sort: ' + allChannelsSortLabel()" - aria-label="Sort channels" + [matTooltip]=" + ('WORKSPACE.SORT_LABEL' | translate) + + (allChannelsSortLabelKey() | translate) + " + [attr.aria-label]="'CHANNELS.SORT_CHANNELS' | translate" > sort_by_alpha @@ -27,21 +30,21 @@
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-sort.util.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-sort.util.ts index 952cd5b5b..b0d1d7b64 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-sort.util.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-sort.util.ts @@ -26,18 +26,19 @@ export function persistPlaylistChannelSortMode( localStorage.setItem(storageKey, mode); } -export function getPlaylistChannelSortModeLabel( +/** Translation key of the sort mode's menu label. */ +export function getPlaylistChannelSortModeLabelKey( mode: PlaylistChannelSortMode ): string { if (mode === 'name-asc') { - return 'Name A-Z'; + return 'WORKSPACE.SORT_NAME_ASC'; } if (mode === 'name-desc') { - return 'Name Z-A'; + return 'WORKSPACE.SORT_NAME_DESC'; } - return 'Playlist Order'; + return 'CHANNELS.SORT_PLAYLIST_ORDER'; } export function sortPlaylistChannelItems( diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index ce685206a..4102ec945 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -210,15 +210,24 @@ } - {{ group.count }} - {{ group.count === 1 ? 'item' : 'items' }} + {{ + (group.count === 1 + ? 'WORKSPACE.CONTEXT.ITEM_COUNT_ONE' + : 'WORKSPACE.CONTEXT.ITEM_COUNT_OTHER' + ) | translate: { count: group.count } + }}
@@ -242,7 +251,9 @@ (click)="setGroupChannelSortMode('server')" > drag_indicator - Playlist Order + {{ + 'CHANNELS.SORT_PLAYLIST_ORDER' | translate + }} @if (groupChannelSortMode() === 'server') { check } @@ -252,7 +263,9 @@ (click)="setGroupChannelSortMode('name-asc')" > sort_by_alpha - Name A-Z + {{ + 'WORKSPACE.SORT_NAME_ASC' | translate + }} @if (groupChannelSortMode() === 'name-asc') { check } @@ -262,7 +275,9 @@ (click)="setGroupChannelSortMode('name-desc')" > sort_by_alpha - Name Z-A + {{ + 'WORKSPACE.SORT_NAME_DESC' | translate + }} @if (groupChannelSortMode() === 'name-desc') { check } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index afee62c73..fe17891cd 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -230,7 +230,9 @@ describe('GroupsViewComponent', () => { it('defaults to playlist order when no saved sort mode exists', () => { expect(component.groupChannelSortMode()).toBe('server'); - expect(component.groupChannelSortLabel()).toBe('Playlist Order'); + expect(component.groupChannelSortLabelKey()).toBe( + 'CHANNELS.SORT_PLAYLIST_ORDER' + ); }); it('keeps the virtual-scroll item size aligned with row EPG density', () => { @@ -259,7 +261,9 @@ describe('GroupsViewComponent', () => { createComponent(); expect(component.groupChannelSortMode()).toBe('name-asc'); - expect(component.groupChannelSortLabel()).toBe('Name A-Z'); + expect(component.groupChannelSortLabelKey()).toBe( + 'WORKSPACE.SORT_NAME_ASC' + ); fixture.destroy(); localStorage.setItem(GROUP_CHANNEL_SORT_STORAGE_KEY, 'invalid'); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 6cf5008bc..f324bd7b9 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -32,7 +32,7 @@ import { import { buildChannelEpgMetadataMap } from '../epg-enrichment.util'; import { PlaylistChannelSortMode, - getPlaylistChannelSortModeLabel, + getPlaylistChannelSortModeLabelKey, persistPlaylistChannelSortMode, restorePlaylistChannelSortMode, sortPlaylistChannelItems, @@ -210,8 +210,8 @@ export class GroupsViewComponent { readonly groupChannelSortMode = signal( restorePlaylistChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY) ); - readonly groupChannelSortLabel = computed(() => - getPlaylistChannelSortModeLabel(this.groupChannelSortMode()) + readonly groupChannelSortLabelKey = computed(() => + getPlaylistChannelSortModeLabelKey(this.groupChannelSortMode()) ); readonly hasSearchQuery = computed( () => diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index 1ddb3ad6d..5d06f79df 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -113,7 +113,7 @@ class="poster__image" [src]="posterUrl()" (error)="onPosterError()" - alt="Poster" + alt="" /> } @else {
@if (showArtwork()) { @@ -52,12 +57,13 @@ >{{ statusIcon() }} } - - @if (session().status === 'error' && session().error) { - {{ session().error }} - } @else { - {{ statusLabelKey() | translate }} - } + + {{ statusLabelKey() | translate: { player: playerLabel() } }}
diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts index ab1c30176..e4ce242c3 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts @@ -24,6 +24,11 @@ class FakeTranslateLoader implements TranslateLoader { EXTERNAL_PLAYBACK_FAILED: 'External player error', }, }, + EXTERNAL_PLAYER: { + ERRORS: { + START_FAILED: 'Could not start {{player}}.', + }, + }, }); } } @@ -155,6 +160,40 @@ describe('ExternalPlaybackDockComponent', () => { expect(dismissSpy).toHaveBeenCalledTimes(1); }); + it('translates a classified failure and keeps the raw detail as a tooltip', () => { + fixture.componentRef.setInput('session', { + ...session, + status: 'error', + error: 'Failed to start MPV player: spawn mpv ENOENT', + errorCode: 'start-failed', + canClose: false, + }); + fixture.detectChanges(); + + const status = fixture.debugElement.query( + By.css('.external-playback-dock__status-text') + ).nativeElement as HTMLElement; + expect(status.textContent?.trim()).toBe('Could not start MPV.'); + expect(status.getAttribute('title')).toBe( + 'Failed to start MPV player: spawn mpv ENOENT' + ); + }); + + it('shows the generic failure, not the raw English detail, without a code', () => { + fixture.componentRef.setInput('session', { + ...session, + status: 'error', + error: 'MPV IPC command timed out', + canClose: true, + }); + fixture.detectChanges(); + + const status = fixture.debugElement.query( + By.css('.external-playback-dock__status-text') + ).nativeElement as HTMLElement; + expect(status.textContent?.trim()).toBe('External player error'); + }); + it('preserves the only stop affordance when an errored process may still be alive', () => { const closeSpy = jest.fn(); const dismissSpy = jest.fn(); diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts index 8b3ed9795..371c34b39 100644 --- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts +++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts @@ -12,7 +12,10 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; -import { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import { + EXTERNAL_PLAYER_ERROR_KEYS, + ExternalPlayerSession, +} from '@iptvnator/shared/interfaces'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; @@ -67,7 +70,9 @@ export class ExternalPlaybackDockComponent { case 'playing': return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_PLAYING'; case 'error': - return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_FAILED'; + return session.errorCode + ? EXTERNAL_PLAYER_ERROR_KEYS[session.errorCode] + : 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_FAILED'; default: return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSED'; } diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 81cddc8b0..3fd93fd69 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -1,6 +1,7 @@ import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-picture.spec-helpers'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; import { Channel } from '@iptvnator/shared/interfaces'; import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls'; import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component'; @@ -52,7 +53,7 @@ describe('ArtPlayerComponent', () => { localStorage.clear(); TestBed.configureTestingModule({ - imports: [ArtPlayerComponent], + imports: [ArtPlayerComponent, TranslateModule.forRoot()], providers: [ { provide: WEB_PLAYER_SHARED_CONTROLS, useValue: false }, ], diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index b8464d100..43924325a 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -56,16 +56,17 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor

- {{ channelName() || 'Radio' }} + {{ channelName() || ('AUDIO_PLAYER.RADIO' | translate) }}

@if (playState() === 'play') { - LIVE + + {{ 'EMBEDDED_MPV.PLAYER.LIVE_BADGE' | translate }} } @else { - PAUSED + {{ 'AUDIO_PLAYER.PAUSED_BADGE' | translate }} } diff --git a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts index 415a2ab28..95d5a8fa7 100644 --- a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts +++ b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts @@ -13,10 +13,10 @@ import { TranslateModule } from '@ngx-translate/core';
live_tv
- The video is playing in external player window.
- Please make sure that mpv/vlc player is correctly installed - on your system.
- See + {{ 'EXTERNAL_PLAYER.INFO_DIALOG.PLAYING' | translate + }}
+ {{ 'EXTERNAL_PLAYER.INFO_DIALOG.INSTALL_HINT' | translate + }}
installation instructions{{ + 'EXTERNAL_PLAYER.INFO_DIALOG.INSTALL_INSTRUCTIONS' + | translate + }} - for more details.
- Don't show anymore + {{ + 'EXTERNAL_PLAYER.INFO_DIALOG.DONT_SHOW_AGAIN' | translate + }}
- LIVE + {{ + 'EMBEDDED_MPV.PLAYER.LIVE_BADGE' | translate + }} @if (showIllustration()) { @if (viewType() === 'ERROR') { - Something went wrong + } @else if (viewType() === 'EMPTY_CATEGORY') { empty category diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts index 8a783418e..2245f02f0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts @@ -112,6 +112,7 @@ describe('WorkspaceShellSearchSyncService', () => { setSearchTerm: jest.fn(), setCategorySearchTerm: jest.fn(), getSelectedCategory: signal(null), + selectedCategoryId: signal(null), }, }, { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts index 26cb3af0f..20f39ef30 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -156,7 +156,10 @@ export class WorkspaceShellSearchService { context, section, translate: (key, params) => this.translateText(key, params), - xtreamCategory: this.xtreamStore.getSelectedCategory(), + // No selected category means the grid lists every item. + xtreamCategory: this.xtreamStore.selectedCategoryId() + ? this.xtreamStore.getSelectedCategory() + : { name: this.translateText('PORTALS.ALL_ITEMS') }, stalkerCategoryName: this.stalkerStore.getSelectedCategoryName(), }), diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts index d7150c568..112159332 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts @@ -56,7 +56,10 @@ describe('WorkspaceShellSearchService on settings routes', () => { { provide: Router, useValue: { navigate: jest.fn() } }, { provide: XtreamStore, - useValue: { getSelectedCategory: () => null }, + useValue: { + getSelectedCategory: () => null, + selectedCategoryId: () => null, + }, }, { provide: StalkerStore, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 72957b928..3db8490b5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -58,6 +58,7 @@ class MockXtreamStore { category_name?: string; name?: string; } | null>(null); + readonly selectedCategoryId = signal(null); readonly vodStreams = signal([{ id: 1 }]); readonly liveStreams = signal([{ id: 2 }]); readonly serialStreams = signal([{ id: 3 }]); @@ -669,6 +670,27 @@ describe('WorkspaceShellFacade', () => { ); }); + it('scopes Xtream search to the translated every-item label without a category', () => { + const xtreamStore = TestBed.inject( + XtreamStore + ) as unknown as MockXtreamStore; + + facade.currentUrl.set('/workspace/xtreams/pl-1/vod?q=neo'); + searchSync.syncSearchFromRoute(); + TestBed.flushEffects(); + + expect(facade.searchScopeLabel()).toBe( + 'WORKSPACE.SHELL.RAIL_MOVIES / PORTALS.ALL_ITEMS' + ); + + xtreamStore.selectedCategoryId.set(7); + xtreamStore.getSelectedCategory.set({ name: 'Drama' }); + + expect(facade.searchScopeLabel()).toBe( + 'WORKSPACE.SHELL.RAIL_MOVIES / Drama' + ); + }); + it('applies q to Xtream category search on vod routes', () => { const xtreamStore = TestBed.inject( XtreamStore diff --git a/package.json b/package.json index 5fa6a78b6..028ca4530 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,9 @@ "smoke:packaged": "node tools/testing/launch-packaged-electron.mjs", "serve:website": "nx serve website", "build:website": "nx build website", - "i18n:check": "node tools/i18n/check-drift.mjs", - "i18n:test": "node --test tools/i18n/check-drift.test.mjs", + "i18n:check": "node tools/i18n/check-drift.mjs && node tools/i18n/check-usage.mjs", + "i18n:test": "node --test tools/i18n/check-drift.test.mjs tools/i18n/check-usage.test.mjs", + "i18n:usage": "node tools/i18n/check-usage.mjs", "i18n:validate": "pnpm run i18n:test && pnpm run i18n:check", "i18n:baseline:update": "node tools/i18n/check-drift.mjs --update-baseline", "perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs", diff --git a/tools/i18n/README.md b/tools/i18n/README.md index 6eb994d37..2553e9500 100644 --- a/tools/i18n/README.md +++ b/tools/i18n/README.md @@ -65,7 +65,15 @@ review every added entry in the diff: pnpm run i18n:baseline:update ``` -The checker's own tests run with `pnpm run i18n:test`; CI runs both through +`pnpm run i18n:check` also runs `check-usage.mjs`, which fails on a key the +renderer uses but `en.json` lacks (ngx-translate would show the raw key). It +scans production `.ts` and `.html` files under `apps/web/src`, +`apps/remote-control-web/src` and `libs` for piped keys (including the +branches of a parenthesised operand), translate calls and +dotted literals in an `en.json` namespace; run it alone with +`pnpm run i18n:usage`. Keys built entirely at runtime are not checked. + +The checkers' own tests run with `pnpm run i18n:test`; CI runs both through `pnpm run i18n:validate`. For a full audit that ignores the baseline, run: ```bash diff --git a/tools/i18n/check-usage.mjs b/tools/i18n/check-usage.mjs new file mode 100644 index 000000000..f378fd47a --- /dev/null +++ b/tools/i18n/check-usage.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node +/** + * Fails when the renderer references a translation key that en.json lacks. + * ngx-translate renders a missing key as the key itself, so the gap only + * shows up on screen; this check catches it before review. + * + * Production `.ts` and `.html` files under the source roots are scanned, + * with comments removed. A key counts as used when it appears as: + * + * - a quoted literal before `| translate`, in templates and inline templates, + * or a ternary/fallback branch of a parenthesised operand before it; + * - the first argument of a translate call: `instant`, `get` or `stream` on a + * receiver whose name contains "translat", a function whose name contains + * "translat" (`translateWithFallback`, `translateText`), `marker` or `t`; + * - a dotted upper-case literal whose first segment is an en.json namespace, + * such as `'PORTALS.ITEMS'` in a constant map. Such a literal may also name + * a group of keys that code completes, as in `${prefix}.TITLE`; + * - the static prefix of a template literal, `EPG.DIALOG.${name}`, which + * must name a group of keys. + * + * Keys assembled entirely at runtime cannot be checked here. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { collectLeaves } from './check-drift.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = resolve(__dirname, '../..'); +export const EN_PATH = resolve(REPO_ROOT, 'apps/web/src/assets/i18n/en.json'); +/** Projects that load apps/web/src/assets/i18n at runtime. */ +export const SOURCE_ROOTS = [ + 'apps/web/src', + 'apps/remote-control-web/src', + 'libs', +]; + +const SKIPPED_DIRS = new Set(['node_modules', 'dist', 'out-tsc', 'testing']); +const SKIPPED_FILE = + /\.(spec|test|e2e|stories)\.ts$|\.d\.ts$|\.spec-[\w-]+\.ts$|harness\.ts$|[\\/]test-stubs[\\/]/; +const KEY = '[A-Z][A-Z0-9_]*(?:\\.[A-Z0-9_]+)*'; +const PIPE_USAGE = /(['"])([A-Za-z0-9_.-]+)\1\s*\|\s*translate\b/g; +const PIPE_AFTER_GROUP = /\)\s*\|\s*translate\b/g; +/** + * A key that a grouped pipe operand can yield: a ternary or fallback branch. + * The literal must also end the operand (end of group, `:`, `||` or `??`), + * so a value compared in a condition (`'ERROR' === x ? …`) is not a key. + */ +const GROUP_OPERAND = new RegExp( + `(^|[?:|])\\s*(['"])(${KEY})\\2(?=\\s*(?:$|:|\\|\\||\\?\\?))`, + 'g' +); +const CALL_USAGE = new RegExp( + `([A-Za-z_$][\\w$]*(?:\\s*\\??\\.\\s*[A-Za-z_$][\\w$]*)*)\\s*\\(\\s*(['"\`])(${KEY})\\2`, + 'g' +); +const QUOTED_DOTTED = /(['"`])([A-Z][A-Z0-9_]*(?:\.[A-Z0-9_]+)+)\1/g; +const TEMPLATE_PREFIX = /`([A-Z][A-Z0-9_]*(?:\.[A-Z0-9_]+)*)\.\$\{/g; +const CODE_TOKENS = + /("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\/[^\n]*|\/\*[\s\S]*?\*\//g; +const HTML_COMMENTS = //g; + +function isTranslateCall(callee) { + const parts = callee.replace(/\s|\?/g, '').split('.'); + const name = parts.at(-1); + if (/translat/i.test(name) || name === 'marker' || name === 't') { + return true; + } + return ( + ['instant', 'get', 'stream'].includes(name) && + parts.length > 1 && + /translat/i.test(parts.at(-2)) + ); +} + +/** + * Replaces comments with blanks, keeping offsets and line numbers. In + * TypeScript the HTML comments of inline templates go too. + */ +export function stripComments(source, kind) { + const blank = (text) => text.replace(/[^\n]/g, ' '); + if (kind === 'html') { + return source.replace(HTML_COMMENTS, blank); + } + return source + .replace(CODE_TOKENS, (match, literal) => + literal ? match : blank(match) + ) + .replace(HTML_COMMENTS, blank); +} + +/** Index of the `(` that opens the group closing at `closeIndex`, or -1. */ +function findGroupStart(code, closeIndex) { + let depth = 0; + for (let i = closeIndex; i >= 0; i -= 1) { + if (code[i] === ')') { + depth += 1; + } else if (code[i] === '(') { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + return -1; +} + +/** + * Keys a parenthesised pipe operand can yield, as in + * `(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate`. Only whole + * branches count (see GROUP_OPERAND), so a literal compared in the condition + * is not read as a key. + */ +function findGroupedPipeKeys(code) { + const references = []; + for (const match of code.matchAll(PIPE_AFTER_GROUP)) { + const open = findGroupStart(code, match.index); + if (open < 0) { + continue; + } + const inner = code.slice(open + 1, match.index); + for (const operand of inner.matchAll(GROUP_OPERAND)) { + references.push({ + key: operand[3], + index: + open + 1 + operand.index + operand[0].indexOf(operand[2]), + kind: 'leaf', + }); + } + } + return references; +} + +/** + * Returns `{ key, index, kind }` for every reference in one file. `kind` is + * `leaf` when the reference must be a translatable string and `group` when a + * group of keys also satisfies it. + */ +export function findKeyReferences(source, kind, namespaces) { + const code = stripComments(source, kind); + const references = []; + for (const match of code.matchAll(PIPE_USAGE)) { + references.push({ key: match[2], index: match.index, kind: 'leaf' }); + } + references.push(...findGroupedPipeKeys(code)); + for (const match of code.matchAll(CALL_USAGE)) { + if (isTranslateCall(match[1])) { + references.push({ + key: match[3], + index: match.index + match[0].lastIndexOf(match[2] + match[3]), + kind: 'leaf', + }); + } + } + for (const match of code.matchAll(QUOTED_DOTTED)) { + if (namespaces.has(match[2].split('.')[0])) { + references.push({ + key: match[2], + index: match.index, + kind: 'group', + }); + } + } + for (const match of code.matchAll(TEMPLATE_PREFIX)) { + if (namespaces.has(match[1].split('.')[0])) { + references.push({ + key: match[1], + index: match.index, + kind: 'prefix', + }); + } + } + return references; +} + +function listSourceFiles(root) { + let entries; + try { + entries = readdirSync(root); + } catch (error) { + if (error?.code === 'ENOENT') { + return []; + } + throw error; + } + return entries.flatMap((entry) => { + const path = resolve(root, entry); + if (statSync(path).isDirectory()) { + return SKIPPED_DIRS.has(entry) ? [] : listSourceFiles(path); + } + return /\.(ts|html)$/.test(entry) && !SKIPPED_FILE.test(path) + ? [path] + : []; + }); +} + +function lineOf(source, index) { + let line = 1; + for (let i = 0; i < index; i += 1) { + if (source.charCodeAt(i) === 10) { + line += 1; + } + } + return line; +} + +/** Runs the check and returns the process exit code. */ +export function run({ + repoRoot = REPO_ROOT, + enPath = EN_PATH, + sourceRoots = SOURCE_ROOTS, + log = console.log, +} = {}) { + let leaves; + try { + leaves = collectLeaves(JSON.parse(readFileSync(enPath, 'utf8'))); + } catch (error) { + log( + `FAIL ${enPath}: ${error instanceof Error ? error.message : error}` + ); + return 1; + } + const groups = new Set(); + for (const key of leaves.keys()) { + const segments = key.split('.'); + for (let end = 1; end < segments.length; end += 1) { + groups.add(segments.slice(0, end).join('.')); + } + } + const namespaces = new Set([...groups].filter((key) => !key.includes('.'))); + + const missing = new Map(); + let files = 0; + for (const root of sourceRoots) { + for (const path of listSourceFiles(resolve(repoRoot, root))) { + files += 1; + const source = readFileSync(path, 'utf8'); + const kind = path.endsWith('.html') ? 'html' : 'ts'; + for (const reference of findKeyReferences( + source, + kind, + namespaces + )) { + const known = + reference.kind === 'leaf' + ? leaves.has(reference.key) + : reference.kind === 'prefix' + ? groups.has(reference.key) + : leaves.has(reference.key) || + groups.has(reference.key); + if (known) { + continue; + } + const location = `${relative(repoRoot, path)}:${lineOf( + source, + reference.index + )}`; + if (!missing.has(reference.key)) { + missing.set(reference.key, new Set()); + } + missing.get(reference.key).add(location); + } + } + } + + if (missing.size === 0) { + log(`ok usage: ${files} files, 0 keys missing from en.json`); + return 0; + } + log( + `FAIL usage: ${missing.size} key(s) are used but missing from en.json:` + ); + for (const [key, locations] of [...missing].sort(([a], [b]) => + a < b ? -1 : 1 + )) { + log(` ${key} ${[...locations].join(', ')}`); + } + log( + 'Add each key to en.json and translate it in every locale (see the i18n-fill skill).' + ); + return 1; +} + +const isMain = + process.argv[1] && + resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + process.exitCode = run(); +} diff --git a/tools/i18n/check-usage.test.mjs b/tools/i18n/check-usage.test.mjs new file mode 100644 index 000000000..c618d466e --- /dev/null +++ b/tools/i18n/check-usage.test.mjs @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, test } from 'node:test'; + +import { findKeyReferences, run, stripComments } from './check-usage.mjs'; + +const EN = { + CLOSE: 'Close', + PORTALS: { + ITEMS: 'items', + DIALOG: { TITLE: 'Title', MESSAGE: 'Message' }, + }, +}; +const NAMESPACES = new Set(['PORTALS']); + +let root; + +beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), 'i18n-usage-')); + writeFileSync(path.join(root, 'en.json'), JSON.stringify(EN)); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function writeSource(relativePath, content) { + const filePath = path.join(root, 'src', relativePath); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); +} + +function check() { + const lines = []; + const code = run({ + repoRoot: root, + enPath: path.join(root, 'en.json'), + sourceRoots: ['src'], + log: (line) => lines.push(line), + }); + return { code, output: lines.join('\n') }; +} + +function keys(source, kind = 'ts') { + return findKeyReferences(source, kind, NAMESPACES).map( + ({ key, kind: referenceKind }) => `${referenceKind}:${key}` + ); +} + +test('fails on a piped key that en.json lacks and names its location', () => { + writeSource( + 'view.component.html', + `

{{ 'CLOSE' | translate }}

\n

{{ 'PORTALS.MISSING' | translate }}

\n` + ); + + const { code, output } = check(); + + assert.equal(code, 1); + assert.match(output, /^FAIL usage: 1 key\(s\)/m); + assert.match(output, /PORTALS\.MISSING {2}src\/view\.component\.html:2$/m); +}); + +test('passes when every referenced key exists', () => { + writeSource( + 'view.ts', + `this.translate.instant('PORTALS.ITEMS');\nconst key = 'PORTALS.DIALOG';\n` + ); + + const { code, output } = check(); + + assert.equal(code, 0); + assert.match(output, /^ok usage: 1 files, 0 keys missing/m); +}); + +test('reads the first argument of translate calls, even across lines', () => { + assert.deepEqual( + keys(`this.translateService.instant(\n 'GHOST',\n { n: 1 }\n);`), + ['leaf:GHOST'] + ); + assert.deepEqual(keys(`translateWithFallback('CLOSE', 'x')`), [ + 'leaf:CLOSE', + ]); + assert.deepEqual(keys(`this.translate.get('CLOSE')`), ['leaf:CLOSE']); + assert.deepEqual(keys(`marker('CLOSE')`), ['leaf:CLOSE']); +}); + +test('reads the branches of a parenthesised pipe operand', () => { + assert.deepEqual( + keys( + "{{ (expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate }}", + 'html' + ), + ['leaf:SHOW_LESS', 'leaf:SHOW_MORE'] + ); + assert.deepEqual( + keys("{{ (label() || 'CLOSE') | translate: { n: 1 } }}", 'html'), + ['leaf:CLOSE'] + ); +}); + +test('does not read a literal compared in the condition as a key', () => { + assert.deepEqual( + keys( + "{{ (status() === 'ERROR' ? 'CLOSE' : other) | translate }}", + 'html' + ), + ['leaf:CLOSE'] + ); + assert.deepEqual( + keys( + "{{ ('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate }}", + 'html' + ), + ['leaf:CLOSE', 'leaf:CLOSE'] + ); + assert.deepEqual( + keys("{{ ('LIVE' ? 'CLOSE' : other) | translate }}", 'html'), + ['leaf:CLOSE'] + ); + assert.deepEqual( + keys("{{ ('CLOSE' || fallback()) | translate }}", 'html'), + ['leaf:CLOSE'] + ); +}); + +test('fails on a missing key in a parenthesised pipe operand', () => { + writeSource( + 'hero.component.html', + "\n" + ); + + const { code, output } = check(); + + assert.equal(code, 1); + assert.match(output, /SHOW_MORE {2}src\/hero\.component\.html:1$/m); + assert.doesNotMatch(output, /CLOSE {2}/); +}); + +test('ignores get() and similar calls on receivers that do not translate', () => { + assert.deepEqual(keys(`params.get('ID'); map.get('NAME');`), []); +}); + +test('a translate call must name a string, not a group of keys', () => { + writeSource('view.ts', `this.translate.instant('PORTALS.DIALOG');\n`); + + const { code, output } = check(); + + assert.equal(code, 1); + assert.match(output, /PORTALS\.DIALOG {2}src\/view\.ts:1$/m); +}); + +test('a constant may name a group of keys that code completes', () => { + writeSource( + 'actions.ts', + "const dialogKey = 'PORTALS.DIALOG';\nthis.translate.instant(`${dialogKey}.TITLE`);\n" + ); + + assert.equal(check().code, 0); +}); + +test('checks dotted literals in known namespaces outside translate calls', () => { + writeSource( + 'keys.ts', + `export const KEYS = { a: 'PORTALS.ITEMS', b: 'PORTALS.GONE' };\n` + ); + + const { code, output } = check(); + + assert.equal(code, 1); + assert.match(output, /PORTALS\.GONE {2}src\/keys\.ts:1$/m); + assert.doesNotMatch(output, /PORTALS\.ITEMS/); +}); + +test('ignores dotted literals outside the en.json namespaces', () => { + assert.deepEqual(keys(`const codec = 'H.264'; const id = 'DB.QUERY';`), []); +}); + +test('a template literal prefix must name a group of keys', () => { + assert.deepEqual(keys('this.t(`PORTALS.DIALOG.${name}`)'), [ + 'prefix:PORTALS.DIALOG', + ]); + writeSource('view.ts', 'this.translate.instant(`PORTALS.NOPE.${name}`);\n'); + + const { code, output } = check(); + + assert.equal(code, 1); + assert.match(output, /PORTALS\.NOPE {2}src\/view\.ts:1$/m); +}); + +test('skips keys that only appear in comments', () => { + writeSource( + 'view.ts', + `// this.translate.instant('PORTALS.OLD')\n/* 'PORTALS.OLDER' | translate */\nconst url = 'https://example.com/a'; // 'PORTALS.TRAILING'\n` + ); + writeSource('view.html', `\n`); + writeSource( + 'inline.component.ts', + "@Component({\n template: `\n \n

{{ 'CLOSE' | translate }}

\n `,\n})\nexport class InlineComponent {}\n" + ); + + assert.equal(check().code, 0); +}); + +test('stripComments keeps string contents and line numbers', () => { + const source = `const a = '// not a comment';\n/* one\ntwo */ const b = 1;`; + + const stripped = stripComments(source, 'ts'); + + assert.equal(stripped.split('\n').length, 3); + assert.match(stripped, /'\/\/ not a comment'/); + assert.doesNotMatch(stripped, /one|two/); +}); + +test('skips spec files, test harnesses and test stubs', () => { + writeSource('view.spec.ts', `translate.instant('PORTALS.SPEC_ONLY');\n`); + writeSource('view.harness.ts', `translate.instant('PORTALS.HARNESS');\n`); + writeSource('view.spec-data.ts', `const k = 'PORTALS.DATA';\n`); + writeSource('test-stubs/stub.ts', `const k = 'PORTALS.STUB';\n`); + + assert.equal(check().code, 0); +}); + +test('fails clearly when en.json cannot be read', () => { + writeFileSync(path.join(root, 'en.json'), '{'); + + const { code, output } = check(); + + assert.equal(code, 1); + assert.match(output, /^FAIL .*en\.json: /m); +}); diff --git a/tools/i18n/identical-en-baseline.json b/tools/i18n/identical-en-baseline.json index 69c96e745..b5b9ca8b6 100644 --- a/tools/i18n/identical-en-baseline.json +++ b/tools/i18n/identical-en-baseline.json @@ -567,6 +567,7 @@ "XTREAM.YOUTUBE_TRAILER": "YouTube Trailer" }, "es": { + "AUDIO_PLAYER.RADIO": "Radio", "CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN": "Origin", "CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer", "CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT": "User-Agent", @@ -696,6 +697,7 @@ "fr": { "ABOUT.VERSION": "Version", "AUDIO_PLAYER.PAUSE": "Pause", + "AUDIO_PLAYER.RADIO": "Radio", "AUDIO_PLAYER.VOLUME": "Volume", "CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN": "Origin", "CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer", @@ -968,6 +970,7 @@ "XTREAM.SERIES_STATUS_PILOT": "Pilot" }, "it": { + "AUDIO_PLAYER.RADIO": "Radio", "AUDIO_PLAYER.VOLUME": "Volume", "CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN": "Origin", "CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer", @@ -1329,6 +1332,7 @@ "YES": "Yes" }, "nl": { + "AUDIO_PLAYER.RADIO": "Radio", "AUDIO_PLAYER.VOLUME": "Volume", "CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN": "Origin", "CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer", @@ -1495,6 +1499,7 @@ "XTREAM.YOUTUBE_TRAILER": "YouTube Trailer" }, "pl": { + "AUDIO_PLAYER.RADIO": "Radio", "CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN": "Origin", "CHANNELS.DETAILS_DIALOG.HTTP_REFERRER": "Referrer", "CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT": "User-Agent",