chore: merge master into xtream timezone fix

This commit is contained in:
4gray committed 2026-05-09 16:15:01 +02:00
commit 8b3b1c9bb7
443 files changed
+38293 -6576

No files matched your search

+1 -84
View File
@@ -1,84 +1 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-start"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code session-end"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code user-prompt-submit"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code stop"
}
]
}
],
"PreToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code pre-task"
}
]
}
],
"PostToolUse": [
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-task"
}
]
},
{
"matcher": "TodoWrite",
"hooks": [
{
"type": "command",
"command": "entire hooks claude-code post-todo"
}
]
}
]
},
"permissions": {
"deny": [
"Read(./.entire/metadata/**)"
]
}
}
{}
+51 -1
View File
@@ -15,7 +15,7 @@ jobs:
build:
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
timeout-minutes: 120
strategy:
matrix:
include:
@@ -88,9 +88,47 @@ jobs:
- name: Build frontend
run: pnpm nx build web --skip-nx-cache
- name: Build embedded MPV runtime (macOS release)
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
shell: bash
run: |
set -euo pipefail
brew install meson ninja pkg-config nasm autoconf automake libtool xz
RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/${{ matrix.arch }}/prefix"
pnpm embedded-mpv:build-runtime -- "${{ matrix.arch }}" "${RUNTIME_PREFIX}"
pnpm embedded-mpv:stage-runtime -- "${{ matrix.arch }}" "${RUNTIME_PREFIX}"
- name: Build backend
env:
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }}
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '1' || '0' }}
run: pnpm run build:backend
- name: Verify embedded MPV build output
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
shell: bash
run: |
set -euo pipefail
echo "::group::Native build output"
find apps/electron-backend/native/build/Release -maxdepth 3 -type f | sort
echo "::endgroup::"
echo "::group::Dist native output"
find dist/apps/electron-backend/native -maxdepth 3 -type f | sort
echo "::endgroup::"
test -f apps/electron-backend/native/build/Release/embedded_mpv.node
test -f dist/apps/electron-backend/native/embedded_mpv.node
test -f dist/apps/electron-backend/native/embedded-mpv-runtime.json
test -f dist/apps/electron-backend/native/lib/libmpv.2.dylib
- name: Validate AppStream metadata
if: matrix.os == 'linux'
run: appstreamcli validate --pedantic --no-net apps/electron-backend/linux/com.fourgray.iptvnator.metainfo.xml
@@ -210,6 +248,10 @@ jobs:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
DEBUG: electron-builder,electron-notarize*
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch }}
# TEMPORARY MASTER ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') && '1' || '0' }}
run: pnpm run make:app
- name: Verify signed macOS app
@@ -263,6 +305,11 @@ jobs:
- name: Make Electron app
if: matrix.os != 'macos' || github.event_name == 'pull_request'
env:
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.arch || '' }}
# TEMPORARY PR TEST: change this back to '0' after manually
# testing the macOS PR artifact with Embedded MPV included.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && github.event_name == 'pull_request' && '1' || '0' }}
run: pnpm run make:app
- name: Verify packaged worker layout
@@ -270,6 +317,9 @@ jobs:
env:
PACKAGE_OS: ${{ matrix.os }}
PACKAGE_ARCH: ${{ matrix.arch || '' }}
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '1' || '0' }}
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
- name: Smoke test packaged Flatpak launcher
+16 -1
View File
@@ -23,6 +23,9 @@ shared/*.js
playwright-report
.nx
server.js
.github/skills
.github/prompts
.agents/skills
# dependencies
node_modules
@@ -65,4 +68,16 @@ testem.log
Thumbs.db
.angular
.astro
.claude/settings.local.json
.gemini
.cursor
.agent
.claude
.codex
.plans
# Native Electron addons
apps/electron-backend/native/build/
vendor/embedded-mpv/darwin-*/include/
vendor/embedded-mpv/darwin-*/lib/
vendor/embedded-mpv/darwin-*/runtime-manifest.json
@@ -0,0 +1,60 @@
# Live EPG Panel Collapse
## Summary
Add a shared, persisted live-TV EPG collapse state across M3U, Xtream, and Stalker. Default remains expanded. When an internal player is active, the EPG can collapse to a slim bottom line showing the current program and an expand button; the player grows to use the freed space. External MPV/VLC playback keeps the existing full EPG-only layout.
## Key Changes
- Add shared state helpers in `@iptvnator/portal/shared/util`:
- storage key: `live-epg-panel-state`
- states: `expanded | collapsed`
- restore invalid/missing values to `expanded`
- Add a shared `LiveEpgPanelComponent` in `shared-portals`:
- inputs: `collapsed`, `summary`, `loading`
- output: `collapsedChange`
- keeps projected EPG content mounted while collapsed, but visually hides and inerts it so existing EPG effects still update current-program state.
- Update the shared live layout SCSS mixin so `.epg.epg-collapsed` has a fixed slim height and `.video-player` fills the remaining space.
- Wire the wrapper into:
- M3U `VideoPlayerComponent`
- Xtream `LiveStreamLayoutComponent`
- Stalker `StalkerLiveStreamLayoutComponent`
- legacy shared `LiveStreamLayoutComponent`, including ArtPlayer as an internal player.
- Add i18n keys for collapse/expand/current-program labels to all locale JSON files, using English fallback where no translation is available.
## Behavior Rules
- Collapse is available only when an internal player is shown: Video.js, HTML5, or ArtPlayer.
- Radio playback still hides EPG entirely.
- External MPV/VLC playback does not render the collapse strip and keeps the full EPG panel.
- The slim line shows current program title, start/end time, and progress when available; otherwise it shows the existing no-program/EPG-unavailable message.
- The shared persisted state applies across all three live-TV modules.
## Tests
- Add unit coverage for restore/persist state validation.
- Add shared `LiveEpgPanelComponent` tests for summary rendering, toggle output, hidden-but-mounted body, and fallback text.
- Extend M3U, Xtream, and Stalker component specs for:
- collapsed state from `localStorage`
- toggle persistence
- collapse only for internal players
- external player keeps full EPG
- current-program summary renders from each module's data source.
- Run:
- `pnpm nx test shared-portals`
- `pnpm nx test ui-epg`
- `pnpm nx test portal-xtream-feature`
- `pnpm nx test portal-stalker-feature`
- `pnpm nx lint playlist-m3u-feature-player`
- `pnpm nx build web`
- Verify in the running Electron app through CDP/agent-browser and Computer Use:
- M3U, Xtream, Stalker live pages
- Video.js, HTML5, ArtPlayer sizing
- collapsed state survives reload/navigation
- light and dark themes do not overlap or clip text.
## Docs And Plan Artifact
- Update `docs/architecture/iptvnator-ui-guidelines.md` with the collapsible live EPG pattern.
- Update module docs where they describe active live EPG behavior, especially M3U and Stalker EPG docs.
- If `IPTVNATOR_WIKI_VAULT` is configured after docs change, run `pnpm wiki:export --mode changed`.
@@ -0,0 +1,49 @@
# Unified Live EPG Toolbar
## Summary
Merge the current-program strip and date navigator into one shared `app-live-epg-panel` toolbar for M3U, Xtream, and Stalker live playback. The EPG list starts directly below the unified toolbar when the panel is expanded. In collapsed mode, keep the slim current-program strip and show a compact date chip.
## Key Changes
- Update `EpgListComponent` to support controlled date state:
- Add `[selectedDate]`, `(selectedDateChange)`, and `[showDateNavigator]`.
- Rename its internal date signal to avoid API collision.
- Keep existing standalone behavior by default, so non-live or external-player layouts still show the internal date row.
- Add small shared EPG date helpers in `@iptvnator/ui/epg`:
- Today date key.
- Parse date key.
- Shift date key by previous/next day.
- Extend `LiveEpgPanelComponent` with optional date navigation:
- Inputs: `[showDateNavigator]`, `[selectedDate]`.
- Output: `(dateNavigation)` emitting `prev | next`.
- Expanded toolbar: collapse button, current-program label/title/time, previous-day button, date label, next-day button, progress line.
- Collapsed toolbar: current-program summary plus compact date chip; the chip opens a small menu for previous/next day on narrow or collapsed states.
- Wire the unified toolbar into M3U, Xtream, and Stalker live layouts.
- Update focused unit specs and e2e selectors that currently assume `.selected-date` / `.next-day` live inside `app-epg-list`.
- Update `docs/architecture/iptvnator-ui-guidelines.md` to document that live inline-player layouts use the unified current-program/date toolbar.
## Test Plan
- Run unit tests:
- `pnpm nx test ui-epg`
- `pnpm nx test shared-portals`
- `pnpm nx test portal-xtream-feature`
- `pnpm nx test portal-stalker-feature`
- `pnpm nx test web`
- Run build/type validation through Nx:
- `pnpm nx build web --configuration=development`
- Use the running Electron app via CDP `127.0.0.1:9222` with `agent-browser`:
- Verify the IPTVnator page target is selected.
- Confirm the old `app-epg-list #channel-header` date row is absent when wrapped by `app-live-epg-panel`.
- Confirm the program list starts immediately below the unified toolbar.
- Click previous/next day in the unified toolbar and verify visible EPG programs change.
- Collapse the panel and verify the slim strip remains, body is inert, and the date chip stays visible.
- Capture a screenshot for visual confirmation.
- If docs changed and `IPTVNATOR_WIKI_VAULT` is configured, run `pnpm wiki:export --mode changed`.
## Assumptions
- Keep external MPV/VLC and non-inline layouts on the existing full EPG list behavior.
- Use existing translation keys for current program, loading, no program info, previous day, and next day.
- Leave unrelated dirty worktree changes untouched.
+35
View File
@@ -0,0 +1,35 @@
# Fix Xtream Type-Level VOD/Series Search
## Summary
- Root cause: `withSelection()` receives the `q` query through the Xtream catalog facade, but `filteredAndSortedContent` returns `sortedContent()` immediately when `selectedCategoryId` is null, so `categorySearchTerm` is ignored on all-items VOD/series routes.
- Fix the store selector so VOD and series search terms filter the current type even when no category is selected.
- No public API, route, schema, or Electron IPC contract changes are needed.
## Key Changes
- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts` so:
- `vod` and `series` apply `filterBySearchTerm` whether `selectedCategoryId` is null or set.
- Existing selected-category behavior, sorting, pagination reset, and `live` behavior stay unchanged.
- Empty search terms still return the full sorted current type.
- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts` with regression coverage for:
- VOD all-items search with `selectedCategoryId === null`.
- Series all-items search with `selectedCategoryId === null`, ideally across multiple categories.
- Existing category-scoped search remains covered and unchanged.
## Test Plan
- Run `pnpm nx test portal-xtream-data-access`.
- Run `pnpm nx test portal-xtream-feature` to confirm the shared catalog route/facade integration still passes.
- Run `pnpm nx build web` as the Angular/Nx build validation.
- Manual Electron check:
- Start `pnpm run serve:backend`.
- Load agent-browser guidance with `agent-browser skills get core` and `agent-browser skills get electron`.
- Attach using `agent-browser --cdp 9222 snapshot -i`.
- Verify an Xtream playlist can search from `/vod?q=...` and `/series?q=...` with no category selected, and still search inside a selected category.
## Assumptions
- Scope is limited to Xtream VOD/series catalog search, not the separate global search page.
- Documentation updates are not expected because this is a regression fix for existing behavior; reassess after implementation.
- Finalized plan file target: `.plans/2026-05-01-xtream-type-search.md`.
@@ -0,0 +1,54 @@
# Resolve Embedded MPV PR Merge Conflicts
## Summary
- Update `feat/embedded-mpv-mac-os-experimental` by merging `origin/master` into the branch, not rebasing, so no force-push is needed.
- Resolve the four simulated conflicts while preserving both sides:
- master’s settings refactor, cover-size setting, live EPG panel work, favorite/recent UX changes
- branch’s macOS Embedded MPV support, player option gating, IPC typings, and package/runtime behavior
- Leave untracked `tools/wiki/` untouched.
## Key Changes
- Settings: keep master’s refactored section components/options and reapply Embedded MPV support in that shape: `EmbeddedMpvSupport` signal, async support probe in `ngOnInit`, and conditional `VideoPlayer.EmbeddedMpv` option before external MPV/VLC.
- Settings store/interfaces: combine `CoverSize`/`coverSize: 'medium'` with `VideoPlayer.EmbeddedMpv`, embedded MPV prepare/sanitize logic, and `isEmbeddedPlayer()` including Video.js, HTML5, ArtPlayer, and Embedded MPV.
- Live playback conflicts: keep master’s `LiveEpgPanel`, date navigation, loading state, and favorite/recent outputs; add Embedded MPV rendering through `WebPlayerViewComponent` with full `ResolvedPortalPlayback` metadata where needed.
- Electron typings/preload: keep master’s new EPG/current-program and recent-batch APIs while preserving all Embedded MPV IPC APIs and session update hooks.
## Public Interfaces
- Preserve existing branch additions:
- `VideoPlayer.EmbeddedMpv = 'embedded-mpv'`
- `EmbeddedMpvSupport`, `EmbeddedMpvSession`, `EmbeddedMpvBounds`
- `window.electron` Embedded MPV support/session methods
- Preserve master additions:
- `CoverSize = 'small' | 'medium' | 'large'`
- `Settings.coverSize`
- `getCurrentProgramsBatch`
- `dbRemoveRecentItemsBatch`
## Test Plan
- Static checks:
- `rg '<<<<<<<|=======|>>>>>>>'`
- `git diff --check`
- `pnpm run typecheck:ci`
- Unit tests:
- `pnpm run test:unit:all`
- E2E tests:
- `pnpm nx run web-e2e:e2e-ci`
- `pnpm nx run electron-backend-e2e:e2e-ci`
- Build/package confidence:
- `pnpm run build:frontend`
- `pnpm run build:backend`
- run local package-layout validation if a package artifact is produced; otherwise rely on the pushed GitHub Actions macOS/Linux/Windows package jobs.
- Docs/wiki:
- Verify `docs/architecture/embedded-mpv-native.md` still matches the merged packaging/runtime behavior.
- If repo docs changed and `IPTVNATOR_WIKI_VAULT` is set, run `pnpm wiki:export --mode changed`; otherwise report it as skipped.
## Commit And Push
- Stage only tracked merge-resolution files plus the saved `.plans/...` file; do not stage untracked `tools/wiki/`.
- Commit as `chore: resolve master conflicts for embedded mpv branch`.
- Push normally with `git push origin feat/embedded-mpv-mac-os-experimental`.
- Recheck PR #877 merge state and checks with `gh pr view --json mergeStateStatus,statusCheckRollup`; note that Vercel was already an external failure unrelated to these conflicts.
+13
View File
@@ -24,6 +24,19 @@ This file provides guidance to coding agents working in this repository.
- The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder.
- Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed.
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed.
- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite.
- Default validation ladder:
1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.
2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.
4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`.
- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below.
- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.
## Electron Debugging (CDP)
- Start the Electron development app with: `nx serve electron-backend`
+34 -11
View File
@@ -24,6 +24,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder.
- Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed.
## Regression Prevention And Test Updates
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed.
- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite.
- Default validation ladder:
1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.
2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.
4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`.
- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below.
- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.
## Project Overview
IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.
@@ -139,20 +152,27 @@ npx --yes agent-browser --cdp 9222 tab list
# Run frontend tests
pnpm run test:frontend
# or
nx test web
pnpm nx test web
# Run backend tests
pnpm run test:backend
# or
nx test electron-backend
pnpm nx test electron-backend
# Run e2e tests (Playwright)
nx e2e web-e2e
# Run targeted E2E tests (Playwright)
pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts
pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts
# Run tests with coverage
nx test web --configuration=ci
# Run broad E2E suites only when the impact justifies it
pnpm nx e2e web-e2e
pnpm nx e2e electron-backend-e2e
# Run tests with coverage when needed
pnpm nx test web --configuration=ci
```
Before finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary.
### Linting
```bash
@@ -534,6 +554,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Built-in HTML5 player with HLS.js or Video.js
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS only): renders libmpv into a custom Cocoa view inside the Electron window. Because the standard `--wid` path is bypassed, mpv's own screensaver inhibition does not apply, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`.
**Radio Player**:
@@ -608,23 +629,25 @@ The factory pattern ensures a single codebase works in both environments without
### Testing Strategy
- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`
- **E2E tests**: Playwright testing the web app
- **E2E tests**: Playwright testing the web app and Electron app
- Backend tests use standard Jest
- Bug fixes should add focused regression coverage unless there is a documented reason not to.
- Use the impact-based validation policy in `Regression Prevention And Test Updates` to choose targeted unit tests, atomized E2E targets, broad suites, or CDP/manual verification.
### Nx Commands
Use `nx` CLI for better performance:
```bash
nx run <project>:<target>
# Example: nx run web:build
# Example: nx run electron-backend:serve
pnpm nx run <project>:<target>
# Example: pnpm nx run web:build
# Example: pnpm nx run electron-backend:serve
```
To run multiple projects:
```bash
nx run-many --target=test --all
pnpm nx run-many --target=test --all
```
### Electron Build Process
+6 -1
View File
@@ -31,9 +31,10 @@ The application is a cross-platform, open-source project built with Electron and
- EPG support (TV Guide) with detailed information
- TV archive/catchup/timeshift functionality
- Group-based channel list
- Read-only M3U channel details from the channel context menu
- Read-only M3U channel details from channel context menus, including favorites and recently viewed
- Favorite channels management
- Global favorites aggregated from all playlists
- Recently viewed live channel removal from row actions and context menus
- HTML video player with HLS.js support or Video.js-based player
- Internationalization with support for 16 languages:
- Arabic
@@ -276,6 +277,10 @@ $ pnpm run serve:frontend
**IPTVnator doesn't provide any playlists or other digital content.**
## Trademark
The name **"IPTVnator"** and the IPTVnator logo are unregistered trademarks of the project owner. The MIT license covers the source code only — it does **not** grant rights to the name or logo. Forks and redistributions (including app-store submissions) must use a different name and their own icon. See [TRADEMARK.md](./TRADEMARK.md) for details.
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#contributors)
+42
View File
@@ -0,0 +1,42 @@
# Trademark Notice
**IPTVnator** (the name) and the IPTVnator logo / icon artwork are trademarks of the IPTVnator project owner ([4gray](https://github.com/4gray)). They are used as unregistered (common-law) trademarks to identify this project and its official builds.
## What the MIT License Covers
The [MIT License](./LICENSE.md) in this repository grants you broad rights to **use, copy, modify, and distribute the source code** of IPTVnator, including for commercial purposes.
## What the MIT License Does **Not** Cover
The MIT License grants rights to the **software**. It does **not** grant any rights to the project's branding. In particular, it does **not** grant you the right to:
- Use the name **"IPTVnator"** (or any confusingly similar variant such as "IPTV-Nator", "IPTVNator", "IPTV Nator", etc.) as the name of your fork, redistribution, app-store listing, paid product, or service.
- Use the IPTVnator **logo, icon, or artwork** to identify your fork or any other product.
- Imply, suggest, or otherwise lead users to believe that your fork or product is the official IPTVnator, is endorsed by the IPTVnator project, or is affiliated with it in any way.
This is consistent with how MIT-licensed projects such as Rust, Node.js, Mozilla, and Kubernetes treat their names and logos: the **code** is open, the **brand** is not.
## What You **Can** Do With a Fork
You are very welcome to fork and modify IPTVnator. When you redistribute it publicly (GitHub releases, app stores, paid SaaS, bundled hardware, etc.), please:
1. **Choose your own name** for the fork. It must not be "IPTVnator" or a confusingly similar variant.
2. **Use your own logo / icon.** Do not reuse the IPTVnator logo, icon, or other artwork files from this repository as the identity of your fork.
3. **Keep the MIT copyright notice** as the license requires.
4. You may, factually, state that your project is "based on IPTVnator" or "a fork of IPTVnator" — this kind of nominative reference is fine and expected.
## Reporting Misuse
If you see a project, app store listing, or service that uses the IPTVnator name or logo in a way that violates this notice — especially on app stores where it can mislead users — please:
- Open an issue on this repository, **or**
- Contact the project owner directly via the contact details on the [GitHub profile](https://github.com/4gray).
Apple App Store and Google Play both provide first-party channels for trademark and copycat-app disputes:
- Apple — [App Store Content Dispute](https://www.apple.com/legal/internet-services/itunes/appstorenotices/)
- Google — [Removing Content From Google (Trademark)](https://support.google.com/legal/answer/3110420)
## Questions
If you are unsure whether a planned use of the IPTVnator name or logo is acceptable, please **ask before shipping**. Opening an issue is the easiest way to get a fast answer.
@@ -8,12 +8,14 @@ import {
defaultXtreamPassword,
defaultXtreamUsername,
expect,
fillWorkspaceSearch,
expectPathname,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForStalkerCatalog,
waitForPortalDebugEvent,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
@@ -236,6 +238,13 @@ test.describe('Electron Catalog Sorting', () => {
vodFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await expectCatalogSearchResetsToFirstPage(
app.mainWindow,
vodSearchTitle
);
await clearCatalogSearch(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await clickFirstGridListCard(app.mainWindow);
await expectPathname(
@@ -252,6 +261,13 @@ test.describe('Electron Catalog Sorting', () => {
seriesFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await expectCatalogSearchResetsToFirstPage(
app.mainWindow,
seriesSearchTitle
);
await clearCatalogSearch(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await clickFirstGridListCard(app.mainWindow);
await expectPathname(
@@ -288,6 +304,16 @@ test.describe('Electron Catalog Sorting', () => {
vodFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
const vodSearchTitle = await firstVisibleGridTitle(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
expectContentChange: false,
});
await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, {
categoryId: vodFixture.categoryId,
title: vodSearchTitle,
type: 'vod',
});
await clearCatalogSearch(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
expectContentChange: false,
});
@@ -299,6 +325,16 @@ test.describe('Electron Catalog Sorting', () => {
seriesFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
const seriesSearchTitle = await firstVisibleGridTitle(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
expectContentChange: false,
});
await expectStalkerCatalogSearchResetsToFirstPage(app.mainWindow, {
categoryId: seriesFixture.categoryId,
title: seriesSearchTitle,
type: 'series',
});
await clearCatalogSearch(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
expectContentChange: false,
});
@@ -401,6 +437,48 @@ async function expectCatalogScrollResetAfterNextPage(
return visibleGridTitles(page);
}
async function expectCatalogSearchResetsToFirstPage(
page: Page,
title: string
): Promise<void> {
await fillWorkspaceSearch(page, title);
await expectCatalogSearchQuery(page, title);
await expectCatalogPageQuery(page, null);
await expect(catalogGridCardByTitle(page, title).first()).toBeVisible({
timeout: 20000,
});
}
async function expectStalkerCatalogSearchResetsToFirstPage(
page: Page,
options: { categoryId: string; title: string; type: 'series' | 'vod' }
): Promise<void> {
await expectCatalogSearchResetsToFirstPage(page, options.title);
await waitForPortalDebugEvent(page, {
provider: 'stalker',
operation: 'get_ordered_list',
predicate: (event) => {
const requestPayload = event.request as {
params?: Record<string, string | number>;
};
return (
requestPayload.params?.['type'] === options.type &&
String(requestPayload.params?.['category']) ===
options.categoryId &&
requestPayload.params?.['search'] === options.title &&
String(requestPayload.params?.['p']) === '1'
);
},
});
}
async function clearCatalogSearch(page: Page): Promise<void> {
await fillWorkspaceSearch(page, '');
await expectCatalogSearchQuery(page, null);
await expectCatalogPageQuery(page, null);
}
async function expectStalkerDetailBackPreservesCatalogPage(
page: Page
): Promise<void> {
@@ -416,13 +494,33 @@ async function expectStalkerDetailBackPreservesCatalogPage(
async function expectCatalogPageQuery(
page: Page,
expectedPage: string
expectedPage: string | null
): Promise<void> {
await expect
.poll(() => new URL(page.url()).searchParams.get('page'))
.toBe(expectedPage);
}
async function expectCatalogSearchQuery(
page: Page,
expectedSearch: string | null
): Promise<void> {
await expect
.poll(() => new URL(page.url()).searchParams.get('q'))
.toBe(expectedSearch);
}
async function firstVisibleGridTitle(page: Page): Promise<string> {
const titles = await visibleGridTitles(page);
const title = titles[0];
if (!title) {
throw new Error('Expected at least one visible catalog grid title.');
}
return title;
}
async function goBackFromDetail(page: Page): Promise<void> {
const backButton = page
.locator('app-content-hero .hero__back-button')
@@ -460,6 +558,14 @@ function catalogGrid(page: Page) {
return page.locator('app-category-content-view app-grid-list').first();
}
function catalogGridCardByTitle(page: Page, title: string) {
return page.locator('.category-content-layout mat-card').filter({
has: page.locator('.title', {
hasText: new RegExp(`^\\s*${escapeRegex(title)}\\s*$`),
}),
});
}
async function ensureCatalogCanGoNext(page: Page): Promise<void> {
const header = page.locator('.category-content-header');
const nextButton = header.getByRole('button', { name: 'Next page' });
@@ -231,6 +231,20 @@ async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
const refreshOverlay = page.locator('app-workspace-shell-import-overlay');
await expect(refreshOverlay).toBeVisible({ timeout: 5000 });
await expect(
refreshOverlay.getByRole('heading', {
name: 'Refreshing playlist',
exact: true,
})
).toBeVisible();
await expect(refreshOverlay).toContainText(/Local library/);
await expect(refreshOverlay).toContainText(
/Preserving your library data|Removing cached streams|Removing cached categories/
);
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
@@ -240,66 +254,60 @@ function sidebarCategoryById(page: Page, categoryId: string): Locator {
);
}
async function expectVisibleSidebarCategoryIds(
page: Page,
expectedIds: string[]
): Promise<void> {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualIds: string[] = [];
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const categoryId = await categories
.nth(index)
.getAttribute('data-category-id');
if (categoryId) {
actualIds.push(categoryId.trim());
}
}
expect(actualIds).toEqual(expectedIds);
}
async function expectVisibleSidebarCategoryNames(
page: Page,
expectedNames: string[]
): Promise<void> {
await expect
.poll(async () => {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualNames: string[] = [];
const count = await categories.count();
try {
await expect
.poll(() => readVisibleSidebarCategoryNames(page), {
timeout: 30000,
})
.toEqual(expectedNames);
} catch (error) {
const actualNames = await readVisibleSidebarCategoryNames(page);
throw new Error(
`Expected visible sidebar categories ${JSON.stringify(
expectedNames
)}, received ${JSON.stringify(actualNames)}`,
{ cause: error }
);
}
}
for (let index = 0; index < count; index += 1) {
const categoryName =
(
await categories
.nth(index)
.locator('.nav-item-label')
.textContent()
)?.trim() ?? '';
async function readVisibleSidebarCategoryNames(page: Page): Promise<string[]> {
const categories = page.locator(
'app-workspace-context-panel .category-item'
);
const actualNames: string[] = [];
const count = await categories.count();
if (categoryName) {
actualNames.push(categoryName);
}
}
for (let index = 0; index < count; index += 1) {
const category = categories.nth(index);
if (!(await category.isVisible())) {
continue;
}
return actualNames;
})
.toEqual(expectedNames);
const categoryName =
(await category.locator('.nav-item-label').textContent())?.trim() ??
'';
if (categoryName) {
actualNames.push(categoryName);
}
}
return actualNames;
}
async function pickSidebarCategory(
page: Page
): Promise<{ id: string; itemCount: number; name: string }> {
let preferredCandidate:
| { id: string; itemCount: number; name: string }
| null = null;
let preferredCandidate: {
id: string;
itemCount: number;
name: string;
} | null = null;
await expect
.poll(async () => {
@@ -1,6 +1,7 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
@@ -94,8 +95,15 @@ test.describe('Dashboard Activation', () => {
/\/workspace\/xtreams\/[^/]+\/favorites$/
);
await expect(
app.mainWindow.locator('.player-toolbar .close-btn').first()
app.mainWindow
.locator(
'app-unified-live-tab .content-container .video-player'
)
.first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, liveTitle).first()
).toHaveClass(/(^|\s)active(\s|$)/);
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
@@ -289,6 +289,60 @@ export async function importM3uPlaylistFromNativeDialog(
await dialog.waitFor({ state: 'detached' });
}
export async function dropM3uPlaylistOntoWorkspace(
page: Page,
filePath: string
): Promise<void> {
await expect(page.locator('.workspace-shell')).toBeVisible({
timeout: 20000,
});
const inputHandle = await page.evaluateHandle(() => {
const input = document.createElement('input');
input.type = 'file';
input.style.position = 'fixed';
input.style.left = '-9999px';
document.body.append(input);
return input;
});
const inputElement = inputHandle.asElement();
if (!inputElement) {
await inputHandle.dispose();
throw new Error('Could not create temporary file input.');
}
await inputElement.setInputFiles(filePath);
await page.evaluate((element) => {
const input = element as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
throw new Error('Temporary file input did not receive a file.');
}
const target = document.querySelector('.workspace-shell');
if (!target) {
throw new Error('Workspace drop target was not found.');
}
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
for (const type of ['dragenter', 'dragover', 'drop']) {
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer,
})
);
}
input.remove();
}, inputElement);
await inputHandle.dispose();
}
export async function addXtreamPortal(
page: Page,
options: {
+79 -8
View File
@@ -13,6 +13,7 @@ import {
resetMockServers,
stalkerMockServer,
test,
waitForXtreamImportToFinish,
waitForStalkerCatalog,
waitForXtreamCatalog,
} from './electron-test-fixtures';
@@ -65,23 +66,93 @@ test.describe('Electron Provider Smoke Tests', () => {
await closeElectronApp(app);
}
});
test('shows refresh overlay immediately from the dashboard Xtream source menu', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow);
await waitForXtreamCatalog(app.mainWindow);
await expectPortalDebugSuccess(app.mainWindow, 'xtream');
await goToDashboard(app.mainWindow);
await refreshRecentXtreamSourceFromDashboard(
app.mainWindow,
defaultXtreamPortalName
);
await waitForXtreamImportToFinish(app.mainWindow);
} finally {
await closeElectronApp(app);
}
});
});
async function expectRecentSourceCard(
page: Page,
title: string
): Promise<void> {
await expect(
page.getByTestId('dashboard-recent-sources-rail')
).toBeVisible({
timeout: 20000,
});
await expect(page.getByTestId('dashboard-recent-sources-rail')).toBeVisible(
{
timeout: 20000,
}
);
await expect(
page.getByTestId('dashboard-recent-sources-rail-card').filter({
hasText: title,
}).first()
page
.getByTestId('dashboard-recent-sources-rail-card')
.filter({
hasText: title,
})
.first()
).toBeVisible({
timeout: 20000,
});
}
async function refreshRecentXtreamSourceFromDashboard(
page: Page,
title: string
): Promise<void> {
const sourceCard = page
.getByTestId('dashboard-recent-sources-rail-card')
.filter({
hasText: title,
})
.first();
await expect(sourceCard).toBeVisible({ timeout: 20000 });
await sourceCard.hover();
await sourceCard
.getByTestId('dashboard-recent-sources-rail-card-actions')
.click();
await page
.getByRole('menuitem', {
name: 'Refresh Xtream playlist from remote',
exact: true,
})
.click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
const refreshOverlay = page.locator('app-workspace-shell-import-overlay');
await expect(refreshOverlay).toBeVisible({ timeout: 5000 });
await expect(
refreshOverlay.getByRole('heading', {
name: 'Refreshing playlist',
exact: true,
})
).toBeVisible();
await expect(refreshOverlay).toContainText(/Local library/);
await expect(refreshOverlay).toContainText(
/Preserving your library data|Removing cached streams|Removing cached categories/
);
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
+93 -23
View File
@@ -73,21 +73,23 @@ test.describe('Electron Recently Viewed', () => {
app.mainWindow,
'Stable Recent Channel'
).first();
const closeButton = app.mainWindow
.locator('.player-toolbar .close-btn')
const livePlayer = app.mainWindow
.locator('app-unified-live-tab .content-container .video-player')
.first();
await item.click();
await expect(closeButton).toBeVisible({ timeout: 20000 });
await expect(livePlayer).toBeVisible({ timeout: 20000 });
await expect(item).toHaveClass(/(^|\s)active(\s|$)/);
await item.click();
await expect(closeButton).toBeVisible({ timeout: 20000 });
await expect(livePlayer).toBeVisible({ timeout: 20000 });
await expect(item).toHaveClass(/(^|\s)active(\s|$)/);
} finally {
await closeElectronApp(app);
}
});
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({
dataDir,
}) => {
const playlistTitle = 'm3u-recent-source.m3u';
@@ -127,10 +129,15 @@ test.describe('Electron Recently Viewed', () => {
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await removeLiveRecentItem(app.mainWindow, 'Recent Channel Two');
await toggleFavoriteForChannel(app.mainWindow, 'Recent Channel Two');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel One']);
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'Recent Channel Two').first()
).toBeVisible({ timeout: 20000 });
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
@@ -145,7 +152,68 @@ test.describe('Electron Recently Viewed', () => {
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel One']);
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'Recent Channel Two').first()
).toBeVisible({ timeout: 20000 });
} finally {
await closeElectronApp(app);
}
});
test('removes one M3U recent live channel from the all-playlists context menu', async ({
dataDir,
}) => {
const filePath = writeTemporaryM3uFile(
dataDir,
'm3u-recent-context-menu-source.m3u',
[
{
groupTitle: 'Live',
name: 'Context Recent One',
url: 'https://streams.example.test/context-recent-one.m3u8',
},
{
groupTitle: 'Live',
name: 'Context Recent Two',
url: 'https://streams.example.test/context-recent-two.m3u8',
},
]
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Context Recent One')
.first()
.click();
await channelItemByTitle(app.mainWindow, 'Context Recent Two')
.first()
.click();
await openPlaylistRecent(app.mainWindow);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Context Recent Two', 'Context Recent One']);
await channelItemByTitle(app.mainWindow, 'Context Recent One')
.first()
.click({ button: 'right' });
await app.mainWindow
.getByRole('menuitem', { name: 'Remove from history' })
.click();
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Context Recent Two']);
await expect(
channelItemByTitle(app.mainWindow, 'Context Recent One')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
@@ -189,7 +257,7 @@ test.describe('Electron Recently Viewed', () => {
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await openPlaylistFavorites(app.mainWindow);
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
await closeUnifiedLiveDetail(app.mainWindow);
await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
@@ -396,7 +464,7 @@ test.describe('Electron Recently Viewed', () => {
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await openPlaylistFavorites(app.mainWindow);
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
await closeUnifiedLiveDetail(app.mainWindow);
await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
@@ -561,11 +629,18 @@ async function clearRecentItems(page: Page, typeLabel: string): Promise<void> {
await page.getByRole('button', { name: 'Yes' }).click();
}
async function closeUnifiedLiveDetail(page: Page): Promise<void> {
const closeButton = page.locator('.player-toolbar .close-btn').first();
await expect(closeButton).toBeVisible({ timeout: 20000 });
await closeButton.click();
async function expectUnifiedLiveDetailOpen(
page: Page,
title: string
): Promise<void> {
await expect(
page
.locator('app-unified-live-tab .content-container .video-player')
.first()
).toBeVisible({ timeout: 20000 });
await expect(channelItemByTitle(page, title).first()).toHaveClass(
/(^|\s)active(\s|$)/
);
}
async function goBackFromDetail(page: Page): Promise<void> {
@@ -634,14 +709,6 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
await episodeCard.click();
}
async function removeLiveRecentItem(page: Page, title: string): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
@@ -651,6 +718,9 @@ async function toggleFavoriteForChannel(
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
'star'
);
}
async function visibleLiveTitles(page: Page): Promise<string[]> {
+181 -3
View File
@@ -206,7 +206,10 @@ test.describe('Electron Workspace Search', () => {
await fillWorkspaceSearch(app.mainWindow, sample.groupTitle);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expectQueryParam(app.mainWindow, 'q', sample.groupTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
@@ -411,6 +414,134 @@ test.describe('Electron Workspace Search', () => {
}
});
test('@search @xtream filters VOD and series root views across categories', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodSample = await fetchXtreamVodFixture(request);
const seriesSample = await fetchXtreamSeriesFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await expectXtreamRootCatalogSearch(app.mainWindow, {
pathPattern: /\/workspace\/xtreams\/[^/]+\/vod$/,
sample: vodSample,
sectionLabel: 'Movies',
});
await expect(
app.mainWindow.locator(headerSearchSelector)
).toHaveValue(vodSample.targetTitle);
await fillWorkspaceSearch(app.mainWindow, '');
await openWorkspaceSection(app.mainWindow, 'Series');
await expectXtreamRootCatalogSearch(app.mainWindow, {
pathPattern: /\/workspace\/xtreams\/[^/]+\/series$/,
sample: seriesSample,
sectionLabel: 'Series',
});
await expect(
app.mainWindow.locator(headerSearchSelector)
).toHaveValue(seriesSample.targetTitle);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream filters live root and category scopes while preserving playback sidebar', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamLiveFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
'Live TV / All Items'
);
await expect(
liveChannelSidebar(app.mainWindow)
.locator('[data-test-id="channel-item"]')
.first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
await channelItemByTitle(app.mainWindow, sample.targetTitle)
.first()
.click();
await expect(
app.mainWindow.locator(
'app-live-stream-layout .content-container .video-player'
)
).toBeVisible({ timeout: 20000 });
await expect(
liveChannelSidebar(app.mainWindow)
.locator('[data-test-id="channel-item"]')
.first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toHaveClass(/(^|\s)active(\s|$)/);
await fillWorkspaceSearch(app.mainWindow, '');
await clickCategoryByNameExact(app.mainWindow, sample.categoryName);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live(?:\/[^/]+)?$/
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
`Live TV / ${sample.categoryName}`
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream promotes dashboard header search into advanced playlist search', async ({
dataDir,
request,
@@ -561,7 +692,10 @@ test.describe('Electron Workspace Search', () => {
);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(
contentCardByTitle(
app.mainWindow,
@@ -801,7 +935,10 @@ test.describe('Electron Workspace Search', () => {
await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
@@ -911,6 +1048,15 @@ async function fetchXtreamVodFixture(
});
}
async function fetchXtreamLiveFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
return fetchXtreamFixture(request, {
categoriesAction: 'get_live_categories',
itemsAction: 'get_live_streams',
});
}
async function fetchXtreamSeriesFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
@@ -1262,6 +1408,10 @@ function xtreamSearchResultCards(page: Page) {
return page.locator('app-search-results .results-grid .content-card');
}
function liveChannelSidebar(page: Page) {
return page.locator('app-live-stream-layout .sidebar');
}
function gridListCardByTitle(page: Page, title: string) {
return page.locator('.category-content-layout mat-card').filter({
has: page.locator('.title', {
@@ -1329,6 +1479,34 @@ async function resolveVisibleXtreamTitles(
};
}
async function expectXtreamRootCatalogSearch(
page: Page,
options: {
pathPattern: RegExp;
sample: XtreamVodFixture;
sectionLabel: string;
}
): Promise<void> {
await openWorkspaceSection(page, options.sectionLabel);
await expectPathname(page, options.pathPattern);
await fillWorkspaceSearch(page, options.sample.targetTitle);
await expectPathname(page, options.pathPattern);
await expectQueryParam(page, 'q', options.sample.targetTitle);
await expectWorkspaceSearchScope(
page,
`${options.sectionLabel} / All Items`
);
const contentLayout = page.locator('.category-content-layout');
await expect(contentLayout).toContainText(
flexibleTextPattern(options.sample.targetTitle),
{ timeout: 20000 }
);
await expect(contentLayout).not.toContainText(
flexibleTextPattern(options.sample.controlTitle)
);
}
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.locator('button.favorite-btn')
@@ -1,13 +1,18 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
createMutableTextServer,
enableRemoteControl,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
LaunchedElectronApp,
m3uFixturePath,
openGlobalRecent,
openSettings,
openWorkspaceSection,
@@ -36,6 +41,86 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
`;
test.describe('Electron Settings', () => {
test('gates external MPV playback behind double-click when enabled', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await installExternalPlayerLaunchCapture(app);
await openSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId(
'external-player-double-click-setting'
)
).toHaveCount(0);
await selectSettingsOption(
app.mainWindow,
'select-video-player',
'mpv'
);
const doubleClickSetting = app.mainWindow.getByTestId(
'external-player-double-click-setting'
);
const doubleClickCheckbox = doubleClickSetting.locator(
'input[type="checkbox"]'
);
await expect(doubleClickSetting).toBeVisible();
await expect(doubleClickCheckbox).not.toBeChecked();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
const firstChannel = channelItemByTitle(
app.mainWindow,
'Channel 1'
).first();
await expect(firstChannel).toBeVisible({ timeout: 20000 });
await firstChannel.click();
await expectExternalPlayerLaunchCount(app, 1);
await expectExternalPlayerLaunch(app, 0, {
player: 'mpv',
title: 'Channel 1',
url: 'https://example.channels/path-to-file/1.m3u8',
});
await openSettings(app.mainWindow);
await expect(doubleClickSetting).toBeVisible();
await doubleClickCheckbox.check();
await saveSettings(app.mainWindow);
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
await resetExternalPlayerLaunches(app);
const secondChannel = channelItemByTitle(
app.mainWindow,
'Positive News TV'
).first();
await expect(secondChannel).toBeVisible({ timeout: 20000 });
await secondChannel.click();
await expect(secondChannel).toHaveClass(/active/);
await expectNoExternalPlayerLaunchesAfterSettled(app);
await secondChannel.dblclick();
await expectExternalPlayerLaunchCount(app, 1);
await expectExternalPlayerLaunch(app, 0, {
player: 'mpv',
title: 'Positive News TV',
url: 'https://example.channels/path-to-file/2.m3u8',
});
} finally {
await closeElectronApp(app);
}
});
test('persists changed desktop settings across app restart', async ({
dataDir,
}) => {
@@ -236,3 +321,112 @@ async function selectSettingsOption(
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
type CapturedExternalPlayerLaunch = {
player: 'mpv' | 'vlc';
title: string;
url: string;
};
const externalPlayerLaunchCaptureKey =
'__iptvnatorE2eExternalPlayerLaunches';
async function installExternalPlayerLaunchCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.evaluate(({ ipcMain }, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, CapturedExternalPlayerLaunch[] | undefined>;
globalRef[captureKey] = [];
const captureLaunch =
(player: 'mpv' | 'vlc') =>
async (
_event: unknown,
url: string,
title: string,
thumbnail?: string | null
) => {
const launches = globalRef[captureKey] ?? [];
const now = new Date().toISOString();
launches.push({
player,
title,
url,
});
globalRef[captureKey] = launches;
return {
canClose: false,
id: `e2e-${player}-${launches.length}`,
player,
startedAt: now,
status: 'opened',
streamUrl: url,
thumbnail: thumbnail ?? null,
title,
updatedAt: now,
};
};
ipcMain.removeHandler('OPEN_MPV_PLAYER');
ipcMain.removeHandler('OPEN_VLC_PLAYER');
ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv'));
ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc'));
}, externalPlayerLaunchCaptureKey);
}
async function getExternalPlayerLaunches(
app: LaunchedElectronApp
): Promise<CapturedExternalPlayerLaunch[]> {
return app.electronApp.evaluate((_, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, CapturedExternalPlayerLaunch[] | undefined>;
return globalRef[captureKey] ?? [];
}, externalPlayerLaunchCaptureKey);
}
async function resetExternalPlayerLaunches(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.evaluate((_, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, CapturedExternalPlayerLaunch[] | undefined>;
globalRef[captureKey] = [];
}, externalPlayerLaunchCaptureKey);
}
async function expectExternalPlayerLaunchCount(
app: LaunchedElectronApp,
count: number
): Promise<void> {
await expect
.poll(async () => (await getExternalPlayerLaunches(app)).length, {
timeout: 10000,
})
.toBe(count);
}
async function expectExternalPlayerLaunch(
app: LaunchedElectronApp,
index: number,
expected: CapturedExternalPlayerLaunch
): Promise<void> {
const launches = await getExternalPlayerLaunches(app);
expect(launches[index]).toMatchObject({
player: expected.player,
url: expected.url,
});
expect(launches[index]?.title.trim()).toBe(expected.title);
}
async function expectNoExternalPlayerLaunchesAfterSettled(
app: LaunchedElectronApp
): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 750));
expect(await getExternalPlayerLaunches(app)).toHaveLength(0);
}
+41 -1
View File
@@ -4,11 +4,13 @@ import {
closeElectronApp,
createMutableTextServer,
deleteSource,
dropM3uPlaylistOntoWorkspace,
dragSourceBefore,
expect,
expectPlaylistUpdatedToast,
expectSourceDialogValues,
getVisibleSourceTitles,
goToDashboard,
importM3uPlaylistFromNativeDialog,
importM3uPlaylistFromUrl,
launchElectronApp,
@@ -425,7 +427,37 @@ https://streams.example.test/original-url.m3u8
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localFilePath);
await dropM3uPlaylistOntoWorkspace(
app.mainWindow,
localFilePath
);
await waitForM3uCatalog(app.mainWindow);
await expect(
app.mainWindow.locator(
'app-playlist-switcher .trigger-refresh-button'
)
).toBeVisible();
await goToDashboard(app.mainWindow);
const sourceCard = app.mainWindow
.getByTestId('dashboard-recent-sources-rail-card')
.filter({
hasText: refreshLocalSourceDisplayName,
})
.first();
await expect(sourceCard).toBeVisible({ timeout: 20000 });
await sourceCard.hover();
await sourceCard
.getByTestId('dashboard-recent-sources-rail-card-actions')
.click();
await expect(
app.mainWindow.getByRole('menuitem', {
name: 'Refresh playlist',
exact: true,
})
).toBeVisible();
await app.mainWindow.keyboard.press('Escape');
await importM3uPlaylistFromUrl(
app.mainWindow,
urlServer.resourceUrl
@@ -434,6 +466,14 @@ https://streams.example.test/original-url.m3u8
name: 'Refresh Xtream Source',
});
await openSources(app.mainWindow);
await expect(
sourceRowByTitle(
app.mainWindow,
refreshLocalSourceDisplayName
)
.first()
.locator('.refresh-btn')
).toBeVisible();
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
{
+74 -33
View File
@@ -31,7 +31,9 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
const currentProgram = fixture.shortEpg[0];
if (!currentProgram) {
throw new Error('Expected the Xtream EPG fixture to include a current program.');
throw new Error(
'Expected the Xtream EPG fixture to include a current program.'
);
}
const app = await launchElectronApp(dataDir, {
env: { TZ: timeZone },
@@ -45,7 +47,10 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickCategoryByNameExact(
app.mainWindow,
fixture.categoryName
);
const channelRow = channelItemByTitle(
app.mainWindow,
@@ -55,14 +60,18 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
await expect
.poll(async () =>
((await channelRow.locator('.epg-title').textContent()) ?? '').trim()
(
(await channelRow
.locator('.epg-title')
.textContent()) ?? ''
).trim()
)
.toBe(currentProgram.title);
await expect
.poll(async () => {
return (await channelRow.locator('.epg-time').allInnerTexts()).map(
(value) => value.trim()
);
return (
await channelRow.locator('.epg-time').allInnerTexts()
).map((value) => value.trim());
})
.toEqual([
formatTimeInZone(currentProgram.startTimestamp, timeZone),
@@ -84,22 +93,14 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
timeZone
);
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
currentDayTitles
);
await expect
.poll(() => visibleProgramTitles(app.mainWindow))
.toEqual(currentDayTitles);
const currentProgramRow = app.mainWindow
.locator('app-epg-list .program-item.current-program')
.first();
await expect(currentProgramRow).toBeVisible();
await expect(currentProgramRow.locator('.program-title')).toHaveText(
currentProgram.title
);
await expect(currentProgramRow.locator('.time')).toHaveText(
`${formatTimeInZone(
currentProgram.startTimestamp,
timeZone
)} - ${formatTimeInZone(currentProgram.stopTimestamp, timeZone)}`
await expectCurrentProgram(
app.mainWindow,
currentProgram,
timeZone
);
const firstFutureDateKey = getFirstFutureDateKey(
@@ -114,25 +115,29 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
firstFutureDateKey!,
timeZone
);
const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!);
const nextDayClicks = dayDifference(
currentDateKey,
firstFutureDateKey!
);
for (let index = 0; index < nextDayClicks; index += 1) {
await app.mainWindow.locator('app-epg-list .next-day').click();
await app.mainWindow
.locator(
'app-live-epg-panel .next-day, app-epg-list .next-day'
)
.click();
}
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
futureDayTitles
);
await expect
.poll(() => visibleProgramTitles(app.mainWindow))
.toEqual(futureDayTitles);
} finally {
await closeElectronApp(app);
}
});
}
function formatTimeInZone(
timestampSeconds: number,
timeZone: string
): string {
function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
return new Intl.DateTimeFormat('en-US', {
timeZone,
hour: '2-digit',
@@ -157,7 +162,9 @@ function formatDateKeyInZone(
const day = parts.find((part) => part.type === 'day')?.value;
if (!year || !month || !day) {
throw new Error(`Failed to format a date key for timezone ${timeZone}.`);
throw new Error(
`Failed to format a date key for timezone ${timeZone}.`
);
}
return `${year}-${month}-${day}`;
@@ -215,14 +222,48 @@ function dayDifference(fromDateKey: string, toDateKey: string): number {
return Math.round((to - from) / (24 * 60 * 60 * 1000));
}
async function visibleProgramTitles(page: Parameters<typeof channelItemByTitle>[0]) {
async function visibleProgramTitles(
page: Parameters<typeof channelItemByTitle>[0]
) {
return page
.locator('app-epg-list .program-title')
.allInnerTexts()
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}
async function getProgressWidthPercent(row: ReturnType<typeof channelItemByTitle>) {
async function expectCurrentProgram(
page: Parameters<typeof channelItemByTitle>[0],
currentProgram: XtreamNormalizedEpgListing,
timeZone: string
): Promise<void> {
const startTime = formatTimeInZone(currentProgram.startTimestamp, timeZone);
const stopTime = formatTimeInZone(currentProgram.stopTimestamp, timeZone);
const currentProgramRow = page
.locator('app-epg-list .program-item.current-program')
.first();
if (await currentProgramRow.isVisible().catch(() => false)) {
await expect(currentProgramRow.locator('.program-title')).toHaveText(
currentProgram.title
);
await expect(currentProgramRow.locator('.time')).toHaveText(
`${startTime}–${stopTime}`
);
return;
}
const summary = page.locator('app-live-epg-panel').first();
await expect(summary.locator('.live-epg-panel__title')).toHaveText(
currentProgram.title
);
await expect(summary.locator('.live-epg-panel__time')).toHaveText(
`${startTime} - ${stopTime}`
);
}
async function getProgressWidthPercent(
row: ReturnType<typeof channelItemByTitle>
) {
const style = await row.locator('.epg-progress-fill').getAttribute('style');
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
return Number.parseFloat(width);
+254
View File
@@ -0,0 +1,254 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const {
copyRuntimeToNativeBuild,
findLibMpv,
patchAddonForBundledRuntime,
validateNoForbiddenRuntimeLinks,
} = require('../../tools/packaging/embedded-mpv-macos.cjs');
const workspaceRoot = process.cwd();
const addonRoot = path.join(workspaceRoot, 'apps', 'electron-backend', 'native');
const outputDir = path.join(addonRoot, 'build', 'Release');
const outputFile = path.join(outputDir, 'embedded_mpv.node');
const outputLibDir = path.join(outputDir, 'lib');
const distNativeDir = path.join(
workspaceRoot,
'dist',
'apps',
'electron-backend',
'native'
);
const unavailableMarkerFile = path.join(outputDir, 'embedded-mpv-unavailable.txt');
const homebrewIncludeDir = '/opt/homebrew/include';
const homebrewLibDir = '/opt/homebrew/lib';
const targetArch =
process.env.IPTVNATOR_EMBEDDED_MPV_ARCH ||
process.env.npm_config_arch ||
process.arch;
const vendoredRuntimeRoot = path.join(
workspaceRoot,
'vendor',
'embedded-mpv',
`darwin-${targetArch}`
);
const vendoredIncludeDir = path.join(vendoredRuntimeRoot, 'include');
const vendoredLibDir = path.join(vendoredRuntimeRoot, 'lib');
const homebrewFallbackEnabled =
process.env.IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW === '1';
const embeddedMpvRequired = ['1', 'true', 'yes', 'on'].includes(
(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV ?? '').trim().toLowerCase()
);
function log(message) {
process.stdout.write(`[embedded-mpv] ${message}\n`);
}
function cleanOutput() {
fs.rmSync(outputFile, { force: true });
fs.rmSync(outputLibDir, { recursive: true, force: true });
fs.rmSync(path.join(outputDir, '.deps'), { recursive: true, force: true });
fs.rmSync(path.join(outputDir, 'obj.target'), {
recursive: true,
force: true,
});
fs.rmSync(path.join(outputDir, 'embedded-mpv-runtime.json'), {
force: true,
});
fs.writeFileSync(
unavailableMarkerFile,
'Embedded MPV native runtime is not available for this build.\n'
);
}
function cleanDistNativeOutput() {
fs.rmSync(distNativeDir, { recursive: true, force: true });
}
function readRuntimeManifest(runtimeRoot) {
const manifestPath = path.join(runtimeRoot, 'runtime-manifest.json');
if (!fs.existsSync(manifestPath)) {
return {};
}
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
function resolveRuntime() {
const vendoredLibMpv = findLibMpv(vendoredLibDir);
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
if (vendoredLibMpv && fs.existsSync(vendoredHeader)) {
return {
origin: 'vendored-lgpl',
includeDir: vendoredIncludeDir,
libDir: vendoredLibDir,
manifest: readRuntimeManifest(vendoredRuntimeRoot),
};
}
if (homebrewFallbackEnabled) {
const homebrewLibMpv = findLibMpv(homebrewLibDir);
const homebrewHeader = path.join(homebrewIncludeDir, 'mpv', 'client.h');
if (homebrewLibMpv && fs.existsSync(homebrewHeader)) {
log(
'Using Homebrew libmpv as a development-only fallback. Release packaging will reject this runtime.'
);
return {
origin: 'homebrew-dev',
includeDir: homebrewIncludeDir,
libDir: homebrewLibDir,
manifest: {
warning:
'Development-only runtime. Do not ship this in release artifacts.',
},
};
}
}
return null;
}
function resolveElectronNodeGypBin() {
const pnpmRoot = path.join(workspaceRoot, 'node_modules', '.pnpm');
if (!fs.existsSync(pnpmRoot)) {
throw new Error('Unable to find node_modules/.pnpm.');
}
const packageDirs = fs
.readdirSync(pnpmRoot, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith('@electron+node-gyp@')
)
.map((entry) => entry.name)
.sort();
for (const packageDir of packageDirs) {
const candidate = path.join(
pnpmRoot,
packageDir,
'node_modules',
'@electron',
'node-gyp',
'bin',
'node-gyp.js'
);
if (fs.existsSync(candidate)) {
return candidate;
}
}
throw new Error('Unable to resolve @electron/node-gyp.');
}
function runNodeGyp(command, env) {
const nodeGypBin = resolveElectronNodeGypBin();
const result = spawnSync(
process.execPath,
[nodeGypBin, command, '--directory', addonRoot],
{
cwd: workspaceRoot,
env,
stdio: 'inherit',
}
);
if (result.status !== 0) {
throw new Error(`node-gyp ${command} failed with status ${result.status ?? 1}.`);
}
}
function main() {
fs.mkdirSync(outputDir, { recursive: true });
cleanDistNativeOutput();
if (process.platform !== 'darwin') {
cleanOutput();
if (embeddedMpvRequired) {
throw new Error('Embedded MPV is required but this host is not macOS.');
}
log('Skipping build on non-macOS host.');
return;
}
const runtime = resolveRuntime();
if (!runtime) {
cleanOutput();
const message = [
`Skipping build because no embedded MPV runtime was found for darwin-${targetArch}.`,
`Expected vendored runtime at ${vendoredRuntimeRoot}.`,
'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.',
].join('\n');
if (embeddedMpvRequired) {
throw new Error(message);
}
log(message);
return;
}
const runtimeManifest = copyRuntimeToNativeBuild({
runtimeLibDir: runtime.libDir,
outputLibDir,
runtimeOrigin: runtime.origin,
runtimeManifest: {
targetArch,
...runtime.manifest,
},
});
fs.rmSync(unavailableMarkerFile, { force: true });
const electronPackageJson = require(path.join(
workspaceRoot,
'node_modules',
'electron',
'package.json'
));
const electronVersion = electronPackageJson.version;
const env = {
...process.env,
npm_config_runtime: 'electron',
npm_config_target: electronVersion,
npm_config_arch: targetArch,
npm_config_disturl: 'https://electronjs.org/headers',
npm_config_build_from_source: 'true',
npm_config_update_binary: 'false',
LIBMPV_INCLUDE_DIR: runtime.includeDir,
LIBMPV_LIBRARY_DIR: outputLibDir,
};
log(
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for darwin-${targetArch}...`
);
runNodeGyp('configure', env);
runNodeGyp('build', env);
if (!fs.existsSync(outputFile)) {
throw new Error(`Build finished without producing ${outputFile}.`);
}
patchAddonForBundledRuntime(outputFile, outputLibDir);
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
outputFile,
...runtimeManifest.dylibs.map((dylib) => path.join(outputLibDir, dylib)),
]);
if (runtime.origin === 'vendored-lgpl' && forbiddenLinkErrors.length > 0) {
throw new Error(forbiddenLinkErrors.join('\n'));
}
log(`Built ${path.relative(workspaceRoot, outputFile)}.`);
}
try {
main();
} catch (error) {
process.stderr.write(
`[embedded-mpv] ${error instanceof Error ? error.message : String(error)}\n`
);
process.exit(1);
}
+37
View File
@@ -0,0 +1,37 @@
{
"targets": [
{
"target_name": "embedded_mpv",
"sources": [
"src/embedded_mpv.mm"
],
"include_dirs": [
"<(module_root_dir)/../../../node_modules/.pnpm/node_modules/node-addon-api",
"<!(node -p \"process.env.LIBMPV_INCLUDE_DIR || '/opt/homebrew/include'\")"
],
"defines": [
"NAPI_CPP_EXCEPTIONS"
],
"cflags_cc": [
"-fexceptions",
"-std=c++20"
],
"xcode_settings": {
"CLANG_CXX_LANGUAGE_STANDARD": "c++20",
"CLANG_ENABLE_OBJC_ARC": "YES",
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
"MACOSX_DEPLOYMENT_TARGET": "10.15",
"OTHER_LDFLAGS": [
"-Wl,-rpath,@loader_path/lib"
]
},
"libraries": [
"-framework AppKit",
"-framework CoreVideo",
"-framework Foundation",
"-framework OpenGL",
"<!(node -e \"const path = require('path'); const dir = process.env.LIBMPV_LIBRARY_DIR || '/opt/homebrew/lib'; process.stdout.write(path.join(dir, 'libmpv.2.dylib'))\")"
]
}
]
}
File diff suppressed because it is too large. Load diff
+50 -2
View File
@@ -11,9 +11,20 @@
"command": "node apps/electron-backend/build-worker.js"
}
},
"build-embedded-mpv": {
"executor": "nx:run-commands",
"cache": false,
"outputs": [
"{workspaceRoot}/apps/electron-backend/native/build/Release"
],
"options": {
"command": "node apps/electron-backend/build-embedded-mpv.js"
}
},
"build": {
"dependsOn": [
"electron-backend:build-worker",
"electron-backend:build-embedded-mpv",
{
"projects": ["web", "remote-control-web"],
"target": "build"
@@ -21,11 +32,29 @@
],
"executor": "nx-electron:build",
"outputs": ["{options.outputPath}"],
"inputs": [
"production",
"^production",
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
],
"options": {
"outputPath": "dist/apps/electron-backend",
"main": "apps/electron-backend/src/main.ts",
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"assets": ["apps/electron-backend/src/assets"]
"assets": [
"apps/electron-backend/src/assets",
{
"glob": "**/*",
"input": "apps/electron-backend/native/build/Release",
"output": "native",
"ignore": [
".deps/**",
"obj.target/**",
"**/.deps/**",
"**/obj.target/**"
]
}
]
},
"configurations": {
"production": {
@@ -44,6 +73,7 @@
"build-e2e": {
"dependsOn": [
"electron-backend:build-worker",
"electron-backend:build-embedded-mpv",
{
"projects": ["web"],
"target": "build-e2e"
@@ -55,11 +85,29 @@
],
"executor": "nx-electron:build",
"outputs": ["{options.outputPath}"],
"inputs": [
"production",
"^production",
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
],
"options": {
"outputPath": "dist/apps/electron-backend",
"main": "apps/electron-backend/src/main.ts",
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"assets": ["apps/electron-backend/src/assets"]
"assets": [
"apps/electron-backend/src/assets",
{
"glob": "**/*",
"input": "apps/electron-backend/native/build/Release",
"output": "native",
"ignore": [
".deps/**",
"obj.target/**",
"**/.deps/**",
"**/obj.target/**"
]
}
]
}
},
"serve": {
@@ -1,8 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron';
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import type {
EmbeddedMpvBounds,
EmbeddedMpvSession,
EmbeddedMpvSupport,
ExternalPlayerSession,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
ResolvedPortalPlayback,
XtreamCategory,
} from 'shared-interfaces';
import {
@@ -14,6 +18,7 @@ import {
const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
@@ -229,6 +234,16 @@ const electronApi = {
ipcRenderer.on(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
return () => ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
},
onEmbeddedMpvSessionUpdate: (
callback: (data: EmbeddedMpvSession) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
data: EmbeddedMpvSession
) => callback(data);
ipcRenderer.on(EMBEDDED_MPV_SESSION_UPDATE, handler);
return () => ipcRenderer.off(EMBEDDED_MPV_SESSION_UPDATE, handler);
},
onDbOperationEvent: (callback: (data: DbOperationEvent) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
callback(data as DbOperationEvent);
@@ -276,6 +291,7 @@ const electronApi = {
updatePlaylistFromFilePath: (filePath: string, title: string) =>
ipcRenderer.invoke('update-playlist-from-file-path', filePath, title),
openPlaylistFromFile: () => ipcRenderer.invoke('open-playlist-from-file'),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
saveFileDialog: (
defaultPath: string,
filters?: { name: string; extensions: string[] }[]
@@ -332,12 +348,82 @@ const electronApi = {
),
closeExternalPlayerSession: (sessionId: string) =>
ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId),
getEmbeddedMpvSupport: (): Promise<EmbeddedMpvSupport> =>
ipcRenderer.invoke('EMBEDDED_MPV_SUPPORT'),
prepareEmbeddedMpv: (): Promise<EmbeddedMpvSupport> =>
ipcRenderer.invoke('EMBEDDED_MPV_PREPARE'),
createEmbeddedMpvSession: (
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
): Promise<EmbeddedMpvSession> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_CREATE_SESSION',
bounds,
title,
initialVolume
),
loadEmbeddedMpvPlayback: (
sessionId: string,
playback: ResolvedPortalPlayback
): Promise<void> =>
ipcRenderer.invoke('EMBEDDED_MPV_LOAD_PLAYBACK', sessionId, playback),
setEmbeddedMpvBounds: (
sessionId: string,
bounds: EmbeddedMpvBounds
): Promise<void> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_BOUNDS', sessionId, bounds),
setEmbeddedMpvPaused: (
sessionId: string,
paused: boolean
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_PAUSED', sessionId, paused),
seekEmbeddedMpv: (
sessionId: string,
seconds: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SEEK', sessionId, seconds),
setEmbeddedMpvVolume: (
sessionId: string,
volume: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_VOLUME', sessionId, volume),
setEmbeddedMpvAudioTrack: (
sessionId: string,
trackId: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_AUDIO_TRACK', sessionId, trackId),
setEmbeddedMpvSubtitleTrack: (
sessionId: string,
trackId: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke(
'EMBEDDED_MPV_SET_SUBTITLE_TRACK',
sessionId,
trackId
),
setEmbeddedMpvSpeed: (
sessionId: string,
speed: number
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_SPEED', sessionId, speed),
setEmbeddedMpvAspect: (
sessionId: string,
aspect: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_SET_ASPECT', sessionId, aspect),
disposeEmbeddedMpvSession: (
sessionId: string
): Promise<EmbeddedMpvSession | null> =>
ipcRenderer.invoke('EMBEDDED_MPV_DISPOSE_SESSION', sessionId),
autoUpdatePlaylists: (playlists) =>
ipcRenderer.invoke('AUTO_UPDATE', playlists),
fetchEpg: (urls: string[]) =>
ipcRenderer.invoke('FETCH_EPG', { url: urls }),
getChannelPrograms: (channelId: string) =>
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
getCurrentProgramsBatch: (channelIds: string[]) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', { channelIds }),
getEpgChannelMetadata: (channelIds: string[]) =>
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
@@ -540,6 +626,9 @@ const electronApi = {
ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId),
dbRemoveRecentItem: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId),
dbRemoveRecentItemsBatch: (
items: { contentId: number; playlistId: string }[]
) => ipcRenderer.invoke('DB_REMOVE_RECENT_ITEMS_BATCH', items),
dbGetContentByXtreamId: (
xtreamId: number,
playlistId: string,
@@ -0,0 +1,48 @@
import { __databaseConnectionTestHooks } from 'database';
function statementContaining(pattern: string): string {
const statement =
__databaseConnectionTestHooks.createTableStatements.find((entry) =>
entry.includes(pattern)
) ??
__databaseConnectionTestHooks.indexMigrationStatements.find((entry) =>
entry.includes(pattern)
) ??
__databaseConnectionTestHooks.columnMigrationStatements.find((entry) =>
entry.includes(pattern)
);
if (!statement) {
throw new Error(`Expected database statement containing "${pattern}"`);
}
return statement;
}
describe('database connection schema', () => {
it('creates fresh Xtream cache tables with the unique targets used by IPC writes', () => {
expect(statementContaining('CREATE TABLE IF NOT EXISTS categories')).toContain(
'UNIQUE(playlist_id, type, xtream_id)'
);
expect(statementContaining('CREATE TABLE IF NOT EXISTS content')).toContain(
'UNIQUE(category_id, type, xtream_id)'
);
});
it('creates the favorites position index only after the position migration can run', () => {
expect(statementContaining('CREATE TABLE IF NOT EXISTS favorites')).toContain(
'position INTEGER DEFAULT 0'
);
expect(
__databaseConnectionTestHooks.createTableStatements.some((entry) =>
entry.includes('favorites_playlist_position_idx')
)
).toBe(false);
expect(statementContaining('ALTER TABLE favorites ADD COLUMN position')).toBe(
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0'
);
expect(statementContaining('favorites_playlist_position_idx')).toContain(
'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx'
);
});
});
@@ -20,7 +20,7 @@ jest.mock('drizzle-orm', () => ({
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import { getContentByXtreamId } from './content.operations';
import { getContentByXtreamId, saveContent } from './content.operations';
function createDbMock(result: unknown[] = []) {
const limit = jest.fn().mockResolvedValue(result);
@@ -91,4 +91,79 @@ describe('content.operations', () => {
expect(eqMock).not.toHaveBeenCalledWith(schema.content.type, 'series');
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
});
it('uses a synchronous better-sqlite transaction callback when saving content', async () => {
const existingContentWhere = jest.fn().mockResolvedValue([{ count: 0 }]);
const existingContentInnerJoin = jest
.fn()
.mockReturnValue({ where: existingContentWhere });
const existingContentFrom = jest
.fn()
.mockReturnValue({ innerJoin: existingContentInnerJoin });
const categoriesWhere = jest.fn().mockResolvedValue([
{
id: 42,
xtreamId: 201,
},
]);
const categoriesFrom = jest
.fn()
.mockReturnValue({ where: categoriesWhere });
const select = jest
.fn()
.mockReturnValueOnce({ from: existingContentFrom })
.mockReturnValueOnce({ from: categoriesFrom });
const run = jest.fn();
const onConflictDoNothing = jest.fn().mockReturnValue({ run });
const values = jest.fn().mockReturnValue({ onConflictDoNothing });
const insert = jest.fn().mockReturnValue({ values });
const transactionResults: unknown[] = [];
const transaction = jest.fn((callback: (tx: unknown) => unknown) => {
const result = callback({ insert });
transactionResults.push(result);
if (
typeof result === 'object' &&
result !== null &&
'then' in result
) {
throw new Error('Transaction function cannot return a promise');
}
return result;
});
const db = {
select,
transaction,
} as unknown as AppDatabase;
await expect(
saveContent(
db,
'playlist-1',
[
{
category_id: '201',
name: 'News Live',
stream_id: '100',
},
],
'live'
)
).resolves.toEqual({ success: true, count: 1 });
expect(transactionResults).toEqual([undefined]);
expect(values).toHaveBeenCalledWith([
expect.objectContaining({
categoryId: 42,
title: 'News Live',
xtreamId: 100,
type: 'live',
}),
]);
expect(run).toHaveBeenCalled();
});
});
@@ -124,7 +124,6 @@ export async function getGlobalRecentlyAdded(
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 200)
: 200;
const addedOrder = sql<number>`CAST(${schema.content.added} AS INTEGER)`;
const whereConditions = [
inArray(schema.content.type, contentTypes),
@@ -136,6 +135,12 @@ export async function getGlobalRecentlyAdded(
whereConditions.push(eq(schema.playlists.type, playlistType));
}
// Sort by `added` directly. Xtream stores Unix-epoch timestamps as
// 10-digit numeric strings (since 2001-09-09), so lexicographic sort
// is equivalent to numeric sort. Wrapping the column in CAST(... AS
// INTEGER) — as we used to — blocks SQLite from using
// idx_content_type_added and forces a full table scan + sort on the
// entire content table (often 100k+ rows) on every dashboard load.
return db
.select({
...selectContentFields(),
@@ -153,7 +158,7 @@ export async function getGlobalRecentlyAdded(
eq(schema.categories.playlistId, schema.playlists.id)
)
.where(and(...whereConditions))
.orderBy(desc(addedOrder))
.orderBy(desc(schema.content.added))
.limit(normalizedLimit);
}
@@ -311,16 +316,19 @@ export async function saveContent(
for (let index = 0; index < values.length; index += chunkSize) {
await checkpointOperation(control);
const chunk = values.slice(index, index + chunkSize);
await db
.insert(schema.content)
.values(chunk)
.onConflictDoNothing({
target: [
schema.content.categoryId,
schema.content.type,
schema.content.xtreamId,
],
});
await db.transaction((tx) => {
tx
.insert(schema.content)
.values(chunk)
.onConflictDoNothing({
target: [
schema.content.categoryId,
schema.content.type,
schema.content.xtreamId,
],
})
.run();
});
totalInserted += chunk.length;
await reportOperationProgress(control, {
phase: 'saving-content',
@@ -365,13 +373,21 @@ export async function clearXtreamImportCache(
contentRows.map((row) => row.id),
100
)) {
await db.delete(schema.content).where(inArray(schema.content.id, chunk));
await db.transaction((tx) => {
tx
.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db
.delete(schema.categories)
.where(inArray(schema.categories.id, chunk));
await db.transaction((tx) => {
tx
.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
}
return { success: true };
@@ -168,15 +168,25 @@ export async function reorderGlobalFavorites(
let current = 0;
const total = updates.length;
// Drizzle's .set() doesn't accept a bare Placeholder — wrap it in an
// sql template so the value resolves to SQL<number> at compile time.
const updateFavoritePosition = db
.update(schema.favorites)
.set({ position: sql<number>`${sql.placeholder('position')}` })
.where(eq(schema.favorites.contentId, sql.placeholder('contentId')))
.prepare();
for (const chunk of chunkValues(updates, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
for (const { content_id, position } of chunk) {
await db
.update(schema.favorites)
.set({ position })
.where(eq(schema.favorites.contentId, content_id));
}
await db.transaction(() => {
for (const { content_id, position } of chunk) {
updateFavoritePosition.execute({
position,
contentId: content_id,
});
}
});
current += chunk.length;
await reportOperationProgress(control, {
@@ -248,26 +248,28 @@ export async function upsertAppPlaylists(
return { success: true, count: 0 };
}
let count = 0;
const rows = playlists
.map((playlist) => buildPlaylistRow(playlist))
.filter((row): row is NonNullable<typeof row> => row !== null);
for (const playlist of playlists) {
const row = buildPlaylistRow(playlist);
if (!row) {
continue;
}
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
count += 1;
if (rows.length === 0) {
return { success: true, count: 0 };
}
return { success: true, count };
await db.transaction((tx) => {
for (const row of rows) {
tx
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
})
.run();
}
});
return { success: true, count: rows.length };
}
export async function getAppPlaylists(db: AppDatabase) {
@@ -395,7 +397,9 @@ export async function deletePlaylist(
for (const chunk of chunkValues(ids, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.delete(table).where(inArray(column, chunk));
await db.transaction((tx) => {
tx.delete(table).where(inArray(column, chunk)).run();
});
current += chunk.length;
await reportOperationProgress(control, {
phase,
@@ -150,3 +150,36 @@ export async function removeRecentItem(
return { success: true };
}
export async function removeRecentItemsBatch(
db: AppDatabase,
items: { contentId: number; playlistId: string }[]
): Promise<{ success: boolean; count: number }> {
if (!Array.isArray(items) || items.length === 0) {
return { success: true, count: 0 };
}
const stmt = db
.delete(schema.recentlyViewed)
.where(
and(
eq(
schema.recentlyViewed.contentId,
sql.placeholder('contentId')
),
eq(
schema.recentlyViewed.playlistId,
sql.placeholder('playlistId')
)
)
)
.prepare();
await db.transaction(() => {
for (const { contentId, playlistId } of items) {
stmt.execute({ contentId, playlistId });
}
});
return { success: true, count: items.length };
}
@@ -123,9 +123,12 @@ export async function deleteXtreamContent(
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db
.delete(schema.content)
.where(inArray(schema.content.id, chunk));
await db.transaction((tx) => {
tx
.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
deletedContent += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-content',
@@ -141,9 +144,12 @@ export async function deleteXtreamContent(
for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db
.delete(schema.categories)
.where(inArray(schema.categories.id, chunk));
await db.transaction((tx) => {
tx
.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
deletedCategories += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-categories',
@@ -264,7 +270,9 @@ export async function restoreXtreamUserData(
for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.favorites).values(chunk);
await db.transaction((tx) => {
tx.insert(schema.favorites).values(chunk).run();
});
restoredFavorites += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-favorites',
@@ -305,7 +313,9 @@ export async function restoreXtreamUserData(
for (const chunk of chunkValues(recentlyViewedValues, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.insert(schema.recentlyViewed).values(chunk);
await db.transaction((tx) => {
tx.insert(schema.recentlyViewed).values(chunk).run();
});
restoredRecentlyViewed += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-recently-viewed',
@@ -35,3 +35,10 @@ handleWorkerRequest(
playlistId,
})
);
handleWorkerRequest(
'DB_REMOVE_RECENT_ITEMS_BATCH',
(items: { contentId: number; playlistId: string }[]) => ({
items,
})
);
@@ -0,0 +1,106 @@
import { ipcMain } from 'electron';
import {
EMBEDDED_MPV_CREATE_SESSION,
EMBEDDED_MPV_DISPOSE_SESSION,
EMBEDDED_MPV_LOAD_PLAYBACK,
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SEEK,
EMBEDDED_MPV_SET_ASPECT,
EMBEDDED_MPV_SET_AUDIO_TRACK,
EMBEDDED_MPV_SET_BOUNDS,
EMBEDDED_MPV_SET_PAUSED,
EMBEDDED_MPV_SET_SPEED,
EMBEDDED_MPV_SET_SUBTITLE_TRACK,
EMBEDDED_MPV_SET_VOLUME,
EMBEDDED_MPV_SUPPORT,
EmbeddedMpvBounds,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import {
EmbeddedMpvNativeService,
embeddedMpvNativeService,
} from '../services/embedded-mpv-native.service';
export default class EmbeddedMpvEvents {
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
return ipcMain;
}
}
function getService(): EmbeddedMpvNativeService {
return embeddedMpvNativeService;
}
ipcMain.handle(EMBEDDED_MPV_SUPPORT, () => getService().getSupport());
ipcMain.handle(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
ipcMain.handle(
EMBEDDED_MPV_CREATE_SESSION,
(
_event,
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
) => getService().createSession(bounds, title, initialVolume)
);
ipcMain.handle(
EMBEDDED_MPV_LOAD_PLAYBACK,
(_event, sessionId: string, playback: ResolvedPortalPlayback) =>
getService().loadPlayback(sessionId, playback)
);
ipcMain.handle(
EMBEDDED_MPV_SET_BOUNDS,
(_event, sessionId: string, bounds: EmbeddedMpvBounds) =>
getService().setBounds(sessionId, bounds)
);
ipcMain.handle(
EMBEDDED_MPV_SET_PAUSED,
(_event, sessionId: string, paused: boolean) =>
getService().setPaused(sessionId, paused)
);
ipcMain.handle(EMBEDDED_MPV_SEEK, (_event, sessionId: string, seconds: number) =>
getService().seek(sessionId, seconds)
);
ipcMain.handle(
EMBEDDED_MPV_SET_VOLUME,
(_event, sessionId: string, volume: number) =>
getService().setVolume(sessionId, volume)
);
ipcMain.handle(
EMBEDDED_MPV_SET_AUDIO_TRACK,
(_event, sessionId: string, trackId: number) =>
getService().setAudioTrack(sessionId, trackId)
);
ipcMain.handle(
EMBEDDED_MPV_SET_SUBTITLE_TRACK,
(_event, sessionId: string, trackId: number) =>
getService().setSubtitleTrack(sessionId, trackId)
);
ipcMain.handle(
EMBEDDED_MPV_SET_SPEED,
(_event, sessionId: string, speed: number) =>
getService().setSpeed(sessionId, speed)
);
ipcMain.handle(
EMBEDDED_MPV_SET_ASPECT,
(_event, sessionId: string, aspect: string) =>
getService().setAspect(sessionId, aspect)
);
ipcMain.handle(EMBEDDED_MPV_DISPOSE_SESSION, (_event, sessionId: string) =>
getService().disposeSession(sessionId)
);
export function shutdownEmbeddedMpv(): void {
getService().shutdown();
}
@@ -1,4 +1,4 @@
import { eq, sql } from 'drizzle-orm';
import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm';
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
@@ -81,6 +81,14 @@ export default class EpgEvents {
}
);
// Get current programs for many channels in a single batched query
ipcMain.handle(
'GET_CURRENT_PROGRAMS_BATCH',
async (_event, args: { channelIds: string[] }) => {
return this.handleGetCurrentProgramsBatch(args.channelIds);
}
);
// Get all channels from database
ipcMain.handle('EPG_GET_CHANNELS', async () => {
return this.handleGetAllChannels();
@@ -635,6 +643,115 @@ export default class EpgEvents {
}
}
/**
* Batch lookup of "currently playing" programs for many channels in a
* single SQL query. Replaces the N+1 pattern where the channel list
* fired one IPC + query per visible channel.
*/
private static async handleGetCurrentProgramsBatch(
channelIds: string[]
): Promise<Record<string, EpgProgram | null>> {
const result: Record<string, EpgProgram | null> = {};
if (!Array.isArray(channelIds) || channelIds.length === 0) {
return result;
}
const validIds = Array.from(
new Set(
channelIds
.map((id) => id?.trim())
.filter((id): id is string => Boolean(id))
)
);
if (validIds.length === 0) {
return result;
}
try {
const db = await getDatabase();
const now = new Date().toISOString();
const rows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
inArray(schema.epgPrograms.channelId, validIds),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
);
for (const row of rows) {
if (!result[row.channelId]) {
const program = this.transformDbRowToEpgProgram(row);
if (this.isValidEpgProgram(program)) {
result[row.channelId] = program;
}
}
}
// Per-channel fallback for IDs that didn't match by exact channel_id.
// Mirrors handleGetChannelPrograms's NOCASE-id and display-name resolution.
const unmatchedIds = validIds.filter((id) => !(id in result));
for (const channelId of unmatchedIds) {
result[channelId] = null;
let channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`
)
.limit(1);
if (channel.length === 0) {
channel = await db
.select()
.from(schema.epgChannels)
.where(
sql`${schema.epgChannels.displayName} = ${channelId} COLLATE NOCASE`
)
.limit(1);
}
if (channel.length === 0) {
continue;
}
const programRows = await db
.select()
.from(schema.epgPrograms)
.where(
and(
eq(schema.epgPrograms.channelId, channel[0].id),
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
)
.limit(1);
if (programRows.length > 0) {
const program = this.transformDbRowToEpgProgram(
programRows[0]
);
if (this.isValidEpgProgram(program)) {
result[channelId] = program;
}
}
}
return result;
} catch (error) {
console.error(
this.loggerLabel,
'Error getting batch current programs:',
error
);
return result;
}
}
/**
* Get all channels from database
*/
@@ -15,6 +15,7 @@ jest.mock('../services/store.service', () => ({
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
VLC_PLAYER_PATH: 'VLC_PLAYER_PATH',
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
store: {
get: jest.fn(),
set: jest.fn(),
@@ -25,13 +26,22 @@ jest.mock('../services/stalker-playback-context.service', () => ({
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
}));
import { ipcMain } from 'electron';
import {
MPV_PLAYER_PATH,
store,
VLC_PLAYER_PATH,
VLC_REUSE_INSTANCE,
} from '../services/store.service';
import {
buildExternalPlayerSpawnSpec,
buildVlcEnqueueCommands,
isRunningInFlatpak,
parseVlcRcPlaybackState,
parseVlcRcNumericResponse,
resolveExternalPlayerLaunchContext,
shouldReuseMpvInstance,
shouldReuseVlcInstance,
shouldUseMpvSocketBridge,
} from './player.events';
@@ -39,6 +49,22 @@ function createPathExists(existingPaths: string[]) {
return (candidatePath: string) => existingPaths.includes(candidatePath);
}
function getIpcMainHandler(channel: string): (...args: unknown[]) => unknown {
const handleMock = ipcMain.handle as unknown as jest.Mock;
const calls = handleMock.mock.calls as Array<
[string, (...args: unknown[]) => unknown]
>;
const match = calls.find(
([registeredChannel]) => registeredChannel === channel
);
if (!match) {
throw new Error(`Missing ipcMain handler for ${channel}`);
}
return match[1];
}
describe('player.events Flatpak launch helpers', () => {
it('detects Flatpak only on Linux when /.flatpak-info exists', () => {
expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'linux')).toBe(
@@ -128,6 +154,12 @@ describe('player.events Flatpak launch helpers', () => {
expect(shouldUseMpvSocketBridge(false)).toBe(true);
});
it('disables VLC reuse only in Flatpak', () => {
expect(shouldReuseVlcInstance(true, true)).toBe(false);
expect(shouldReuseVlcInstance(true, false)).toBe(true);
expect(shouldReuseVlcInstance(false, false)).toBe(false);
});
it('parses numeric VLC RC responses that include the prompt prefix', () => {
expect(
parseVlcRcNumericResponse(`VLC media player 3.0.20 Vetinari
@@ -170,3 +202,90 @@ Command Line Interface initialized. Type \`help' for help.
).toBe('stopped');
});
});
describe('player.events external player path settings', () => {
beforeEach(() => {
(store.set as unknown as jest.Mock).mockClear();
});
it('stores cleared VLC player paths as an empty string', () => {
getIpcMainHandler('SET_VLC_PLAYER_PATH')({}, ' ');
expect(store.set).toHaveBeenCalledWith(VLC_PLAYER_PATH, '');
});
it('trims custom MPV player paths before storing them', () => {
getIpcMainHandler('SET_MPV_PLAYER_PATH')(
{},
' /Applications/mpv.app/Contents/MacOS/mpv '
);
expect(store.set).toHaveBeenCalledWith(
MPV_PLAYER_PATH,
'/Applications/mpv.app/Contents/MacOS/mpv'
);
});
it('persists the VLC reuse-instance preference', () => {
getIpcMainHandler('SET_VLC_REUSE_INSTANCE')({}, true);
expect(store.set).toHaveBeenCalledWith(VLC_REUSE_INSTANCE, true);
});
});
describe('buildVlcEnqueueCommands', () => {
it('clears the playlist and adds the URL with no extra options', () => {
expect(
buildVlcEnqueueCommands({ url: 'http://stream.example/a.m3u8' })
).toEqual(['clear', 'add http://stream.example/a.m3u8']);
});
it('attaches per-input HTTP options inline with the add command', () => {
const commands = buildVlcEnqueueCommands({
url: 'http://stream.example/a.m3u8',
title: 'Channel One',
userAgent: 'Custom/1.0',
referer: 'https://referer.example',
headers: { 'X-Token': 'abc' },
});
expect(commands[0]).toBe('clear');
expect(commands[1]).toBe(
'add http://stream.example/a.m3u8 :http-user-agent=Custom/1.0 :http-referrer=https://referer.example :http-header=X-Token: abc :meta-title=Channel One'
);
});
it('falls back to origin when referer is absent', () => {
const commands = buildVlcEnqueueCommands({
url: 'http://stream.example/a.m3u8',
origin: 'https://origin.example',
});
expect(commands[1]).toContain(
':http-referrer=https://origin.example'
);
});
it('appends a seek command when startTime is provided', () => {
const commands = buildVlcEnqueueCommands({
url: 'http://stream.example/a.m3u8',
startTime: 42.7,
});
expect(commands).toEqual([
'clear',
'add http://stream.example/a.m3u8',
'seek 42',
]);
});
it('skips empty header values', () => {
const commands = buildVlcEnqueueCommands({
url: 'http://stream.example/a.m3u8',
headers: { 'X-Empty': ' ', 'X-Real': 'value' },
});
expect(commands[1]).toContain(':http-header=X-Real: value');
expect(commands[1]).not.toContain('X-Empty');
});
});
@@ -11,6 +11,7 @@ import {
MPV_REUSE_INSTANCE,
store,
VLC_PLAYER_PATH,
VLC_REUSE_INSTANCE,
} from '../services/store.service';
import { ChildProcess, spawn } from 'child_process';
@@ -31,6 +32,10 @@ let mpvProcess: ChildProcess | null = null;
let mpvSocketPath: string | null = null;
let positionPollingInterval: NodeJS.Timeout | null = null;
// Keep track of the running VLC process for reuse
let vlcProcess: ChildProcess | null = null;
let vlcRcPort: number | null = null;
interface ExternalPlaybackSnapshot {
positionSeconds: number;
durationSeconds: number | null;
@@ -77,11 +82,17 @@ export function isRunningInFlatpak(
return platform === 'linux' && pathExists('/.flatpak-info');
}
function normalizeCustomPlayerPath(value: string | undefined): string | null {
function normalizeCustomPlayerPath(
value: string | null | undefined
): string | null {
const trimmedValue = value?.trim();
return trimmedValue ? trimmedValue : null;
}
function normalizePlayerPathForStore(value: string | null | undefined): string {
return normalizeCustomPlayerPath(value) ?? '';
}
function getDefaultPlayerPath(
player: ExternalPlayerName,
options: PlayerPathOptions = {}
@@ -163,6 +174,96 @@ export function shouldUseMpvSocketBridge(
return !isFlatpak;
}
export function shouldReuseVlcInstance(
requestedReuseInstance: boolean,
isFlatpak: boolean = isRunningInFlatpak()
): boolean {
return !isFlatpak && requestedReuseInstance;
}
export function buildVlcEnqueueCommands(options: {
url: string;
title?: string;
userAgent?: string;
referer?: string;
origin?: string;
headers?: Record<string, string>;
startTime?: number;
}): string[] {
const inputOptions: string[] = [];
if (options.userAgent) {
inputOptions.push(`:http-user-agent=${options.userAgent}`);
}
if (options.referer) {
inputOptions.push(`:http-referrer=${options.referer}`);
} else if (options.origin) {
inputOptions.push(`:http-referrer=${options.origin}`);
}
Object.entries(options.headers ?? {}).forEach(([name, value]) => {
if (!name || value === undefined || value === null) return;
const trimmedValue = String(value).trim();
if (!trimmedValue) return;
inputOptions.push(`:http-header=${name}: ${trimmedValue}`);
});
if (options.title) {
inputOptions.push(`:meta-title=${options.title}`);
}
const inputLine =
inputOptions.length > 0
? `${options.url} ${inputOptions.join(' ')}`
: options.url;
const commands = ['clear', `add ${inputLine}`];
if (options.startTime && Number.isFinite(options.startTime)) {
commands.push(`seek ${Math.floor(options.startTime)}`);
}
return commands;
}
function sendVlcRcCommand(port: number, command: string): Promise<void> {
return new Promise((resolve, reject) => {
const client = createConnection({ port, host: '127.0.0.1' });
let settled = false;
const finish = (err?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
err ? reject(err) : resolve();
};
const timeoutHandle = setTimeout(
() => finish(new Error('VLC RC command timed out')),
2000
);
client.on('connect', () => {
client.write(`${command}\n`);
});
// The VLC RC interface emits its '> ' prompt after each command finishes.
client.on('data', (chunk) => {
if (chunk.toString().includes('>')) {
finish();
}
});
client.on('error', (err) => finish(err));
});
}
async function sendVlcRcCommands(
port: number,
commands: string[]
): Promise<void> {
for (const command of commands) {
await sendVlcRcCommand(port, command);
}
}
function buildPlayerStartError(
player: 'MPV' | 'VLC',
error: Error,
@@ -921,10 +1022,14 @@ ipcMain.handle(
}
);
ipcMain.handle('SET_MPV_PLAYER_PATH', (_event, mpvPlayerPath) => {
console.log('... setting mpv player path', mpvPlayerPath);
store.set(MPV_PLAYER_PATH, mpvPlayerPath);
});
ipcMain.handle(
'SET_MPV_PLAYER_PATH',
(_event, mpvPlayerPath: string | null | undefined) => {
const normalizedPlayerPath = normalizePlayerPathForStore(mpvPlayerPath);
console.log('... setting mpv player path', normalizedPlayerPath);
store.set(MPV_PLAYER_PATH, normalizedPlayerPath);
}
);
ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => {
console.log('... setting mpv reuse instance', reuseInstance);
@@ -968,6 +1073,14 @@ ipcMain.handle(
getVlcPath({ isFlatpak }),
{ isFlatpak }
);
const requestedReuseInstance = store.get(
VLC_REUSE_INSTANCE,
false
);
const reuseInstance = shouldReuseVlcInstance(
requestedReuseInstance,
isFlatpak
);
const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {};
const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders)
? fallbackHeaders
@@ -990,17 +1103,118 @@ ipcMain.handle(
mergedHeaders['User-Agent'] ??
mergedHeaders['user-agent'] ??
undefined;
console.log('Opening VLC player with path:', vlcLaunchContext.playerPath);
console.log('VLC launch mode:', vlcLaunchContext.mode);
console.log('URL:', url);
// console.log('User-Agent:', userAgent);
// console.log('Referer:', referer);
// console.log('Origin:', origin);
// console.log('Start Time:', startTime);
console.log('[VLC] Opening player', {
path: vlcLaunchContext.playerPath,
launchMode: vlcLaunchContext.mode,
requestedReuseInstance,
reuseInstance,
stream: maskUrlForLogs(url),
hasUserAgent: Boolean(effectiveUserAgent),
hasReferer: Boolean(effectiveReferer),
hasOrigin: Boolean(effectiveOrigin),
hasContentInfo: Boolean(contentInfo),
startTime: startTime ?? null,
});
// Get free port for RC interface
// Try to drive an already-running VLC via its RC port instead of
// spawning a new instance.
if (
reuseInstance &&
vlcProcess &&
!vlcProcess.killed &&
vlcRcPort
) {
console.log(
'Reusing existing VLC instance on RC port',
vlcRcPort
);
try {
const enqueueCommands = buildVlcEnqueueCommands({
url,
title,
userAgent: effectiveUserAgent,
referer: effectiveReferer,
origin: effectiveOrigin,
headers: mergedHeaders,
startTime,
});
await sendVlcRcCommands(vlcRcPort, enqueueCommands);
console.log(
'Successfully loaded new URL in existing VLC instance'
);
const reusedRcPort = vlcRcPort;
let lastReusedSnapshot: ExternalPlaybackSnapshot | null =
null;
externalPlayerSessions.attachCloser(
session.id,
async () => {
try {
await sendVlcRcCommand(reusedRcPort, 'stop');
} catch {
if (vlcProcess && !vlcProcess.killed) {
vlcProcess.kill();
}
}
}
);
if (contentInfo) {
startVlcPositionPolling(
reusedRcPort,
contentInfo,
session.id,
(snapshot) => {
lastReusedSnapshot = snapshot;
},
() => {
if (
lastReusedSnapshot &&
contentInfo &&
externalPlayerSessions.getSession(
session.id
)?.status !== 'closed'
) {
sendPlaybackPositionUpdate(
session.id,
contentInfo,
lastReusedSnapshot
);
}
externalPlayerSessions.markClosed(session.id);
}
);
} else {
stopVlcPositionPolling();
}
return (
externalPlayerSessions.markOpened(session.id) ?? session
);
} catch (err) {
console.error(
'Failed to reuse existing VLC, spawning fresh:',
err
);
// Tracked process is not responsive; clear state and fall
// through to spawn a brand new VLC.
if (vlcProcess && !vlcProcess.killed) {
try {
vlcProcess.kill();
} catch {
/* ignore */
}
}
vlcProcess = null;
vlcRcPort = null;
stopVlcPositionPolling();
}
}
// Get free port for RC interface (always allocate when reuse is
// enabled so future calls can drive this instance).
let rcPort = 0;
if (contentInfo) {
if (contentInfo || reuseInstance) {
try {
rcPort = await getFreePort();
console.log('Using VLC RC port:', rcPort);
@@ -1063,12 +1277,24 @@ ipcMain.handle(
vlcLaunchContext,
playerArgs
);
const trackProcess = reuseInstance && !isRetry;
const proc = spawn(spawnSpec.command, spawnSpec.args, {
shell: false,
detached: true,
stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown
detached: !trackProcess,
stdio: trackProcess
? ['ignore', 'pipe', 'pipe']
: 'ignore',
});
if (trackProcess && rcPort > 0) {
vlcProcess = proc;
vlcRcPort = rcPort;
console.log(
'Tracking VLC process for reuse on RC port',
rcPort
);
}
const markVlcSessionClosed = () => {
if (
externalPlayerSessions.getSession(session.id)?.status ===
@@ -1152,6 +1378,10 @@ ipcMain.handle(
proc.on('error', (err) => {
console.error('Failed to start VLC player:', err);
if (vlcProcess === proc) {
vlcProcess = null;
vlcRcPort = null;
}
if (!isRetry && rcPort > 0) {
console.log('Retrying VLC without RC interface...');
// Retry without RC args
@@ -1179,6 +1409,10 @@ ipcMain.handle(
proc.on('exit', (code) => {
console.log(`VLC exited with code ${code}`);
if (vlcProcess === proc) {
vlcProcess = null;
vlcRcPort = null;
}
stopVlcPositionPolling();
if (
@@ -1228,7 +1462,9 @@ ipcMain.handle(
externalPlayerSessions.markClosed(session.id);
});
proc.unref();
if (!trackProcess) {
proc.unref();
}
};
spawnVlc(args);
@@ -1247,9 +1483,26 @@ ipcMain.handle(
}
);
ipcMain.handle('SET_VLC_PLAYER_PATH', (_event, vlcPlayerPath) => {
console.log('... setting vlc player path', vlcPlayerPath);
store.set(VLC_PLAYER_PATH, vlcPlayerPath);
ipcMain.handle(
'SET_VLC_PLAYER_PATH',
(_event, vlcPlayerPath: string | null | undefined) => {
const normalizedPlayerPath = normalizePlayerPathForStore(vlcPlayerPath);
console.log('... setting vlc player path', normalizedPlayerPath);
store.set(VLC_PLAYER_PATH, normalizedPlayerPath);
}
);
ipcMain.handle('SET_VLC_REUSE_INSTANCE', (_event, reuseInstance: boolean) => {
console.log('... setting vlc reuse instance', reuseInstance);
store.set(VLC_REUSE_INSTANCE, reuseInstance);
// If disabling reuse, kill the existing tracked process
if (!reuseInstance && vlcProcess && !vlcProcess.killed) {
console.log('Disabling reuse, cleaning up existing VLC process');
vlcProcess.kill();
vlcProcess = null;
vlcRcPort = null;
}
});
ipcMain.handle(
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron';
import {
MPV_REUSE_INSTANCE,
store,
VLC_REUSE_INSTANCE,
} from '../services/store.service';
import { httpServer } from '../server/http-server';
@@ -19,6 +20,10 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance);
}
if (arg.vlcReuseInstance !== undefined) {
store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance);
}
// Handle remote control settings
if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) {
const enabled = arg.remoteControl ?? store.get('remoteControl', false);
@@ -0,0 +1,215 @@
import type {
EmbeddedMpvBounds,
EmbeddedMpvSessionStatus,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
const powerSaveBlockerMock = {
start: jest.fn<number, [string]>(),
stop: jest.fn<void, [number]>(),
isStarted: jest.fn<boolean, [number]>(),
};
jest.mock('electron', () => ({
app: {
isPackaged: true,
getAppPath: () => '/mock/app.asar',
},
powerSaveBlocker: powerSaveBlockerMock,
}));
const mainWindowSendMock = jest.fn();
const mainWindowMock = {
isDestroyed: () => false,
getNativeWindowHandle: () => Buffer.alloc(8),
webContents: { send: mainWindowSendMock },
};
jest.mock('../app', () => ({
__esModule: true,
default: {
get mainWindow() {
return mainWindowMock;
},
},
}));
interface MockSnapshot {
status: EmbeddedMpvSessionStatus;
positionSeconds: number;
durationSeconds: number | null;
volume: number;
streamUrl: string;
audioTracks?: never[];
selectedAudioTrackId?: number | null;
error?: string;
}
interface MockAddon {
isSupported: jest.Mock<boolean, []>;
createSession: jest.Mock<
string,
[Buffer, EmbeddedMpvBounds, string?, number?]
>;
loadPlayback: jest.Mock<void, [string, ResolvedPortalPlayback]>;
setBounds: jest.Mock<void, [string, EmbeddedMpvBounds]>;
setPaused: jest.Mock<void, [string, boolean]>;
seek: jest.Mock<void, [string, number]>;
setVolume: jest.Mock<void, [string, number]>;
setAudioTrack: jest.Mock<void, [string, number]>;
getSessionSnapshot: jest.Mock<MockSnapshot | null, [string]>;
disposeSession: jest.Mock<void, [string]>;
}
function createMockAddon(): MockAddon {
return {
isSupported: jest.fn().mockReturnValue(true),
createSession: jest.fn(),
loadPlayback: jest.fn(),
setBounds: jest.fn(),
setPaused: jest.fn(),
seek: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
getSessionSnapshot: jest.fn(),
disposeSession: jest.fn(),
};
}
const BOUNDS: EmbeddedMpvBounds = { x: 0, y: 0, width: 100, height: 100 };
describe('EmbeddedMpvNativeService power blocker', () => {
let EmbeddedMpvNativeService: typeof EmbeddedMpvNativeServiceType;
let service: EmbeddedMpvNativeServiceType;
let addon: MockAddon;
let nextBlockerId: number;
beforeEach(async () => {
jest.resetModules();
powerSaveBlockerMock.start.mockReset();
powerSaveBlockerMock.stop.mockReset();
powerSaveBlockerMock.isStarted.mockReset();
mainWindowSendMock.mockReset();
nextBlockerId = 1;
powerSaveBlockerMock.start.mockImplementation(() => nextBlockerId++);
powerSaveBlockerMock.isStarted.mockReturnValue(true);
({ EmbeddedMpvNativeService } = await import(
'./embedded-mpv-native.service'
));
service = new EmbeddedMpvNativeService();
addon = createMockAddon();
// The addon is normally loaded via createRequire from a vendored .node
// file. For unit tests we inject a mock implementation directly.
(service as unknown as { addon: MockAddon }).addon = addon;
});
afterEach(() => {
service.shutdown();
});
function startSession(
sessionId: string,
snapshot: MockSnapshot
): void {
addon.createSession.mockReturnValueOnce(sessionId);
addon.getSessionSnapshot.mockReturnValueOnce(snapshot);
service.createSession(BOUNDS, '', 1);
}
function snapshot(
status: EmbeddedMpvSessionStatus,
overrides: Partial<MockSnapshot> = {}
): MockSnapshot {
return {
status,
positionSeconds: 0,
durationSeconds: null,
volume: 1,
streamUrl: 'mock://stream',
...overrides,
};
}
it('does not acquire a blocker for a loading session', () => {
startSession('s1', snapshot('loading'));
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
});
it('acquires a single prevent-display-sleep blocker once a session is playing', () => {
startSession('s1', snapshot('loading'));
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing'));
service.setPaused('s1', false);
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
expect(powerSaveBlockerMock.start).toHaveBeenCalledWith(
'prevent-display-sleep'
);
// Subsequent refreshes while still playing must not start a second blocker.
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing'));
service.setPaused('s1', false);
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
});
it('releases the blocker when the session transitions to paused', () => {
startSession('s1', snapshot('playing'));
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
service.setPaused('s1', true);
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
expect(powerSaveBlockerMock.stop).toHaveBeenCalledWith(1);
});
it('keeps the blocker held while any other session is still playing', () => {
startSession('s1', snapshot('playing'));
startSession('s2', snapshot('playing'));
// Only one blocker for both sessions.
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
// s1 pauses — s2 still playing, so do not release.
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
service.setPaused('s1', true);
expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled();
// s2 pauses — now there is nothing playing.
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
service.setPaused('s2', true);
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
});
it('releases the blocker when the playing session is disposed', () => {
startSession('s1', snapshot('playing'));
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
service.disposeSession('s1');
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
});
it('releases the blocker on shutdown', () => {
startSession('s1', snapshot('playing'));
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
service.shutdown();
expect(powerSaveBlockerMock.stop).toHaveBeenCalled();
});
it('skips powerSaveBlocker.stop if the assertion was already cleared externally', () => {
startSession('s1', snapshot('playing'));
powerSaveBlockerMock.isStarted.mockReturnValue(false);
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
service.setPaused('s1', true);
expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,660 @@
import { app, powerSaveBlocker } from 'electron';
import { existsSync, readFileSync } from 'fs';
import { createRequire } from 'module';
import path from 'path';
import App from '../app';
import {
EmbeddedMpvAudioTrack,
EmbeddedMpvBounds,
EmbeddedMpvCapabilities,
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
EmbeddedMpvSubtitleTrack,
EmbeddedMpvSupport,
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
} from 'shared-interfaces';
interface NativeEmbeddedMpvSessionSnapshot {
status: EmbeddedMpvSessionStatus;
positionSeconds: number;
durationSeconds: number | null;
volume: number;
streamUrl: string;
audioTracks?: EmbeddedMpvAudioTrack[];
selectedAudioTrackId?: number | null;
subtitleTracks?: EmbeddedMpvSubtitleTrack[];
selectedSubtitleTrackId?: number | null;
playbackSpeed?: number;
aspectOverride?: string;
error?: string;
}
interface NativeEmbeddedMpvAddon {
isSupported(): boolean;
createSession(
windowHandle: Buffer,
bounds: EmbeddedMpvBounds,
title?: string,
initialVolume?: number
): string;
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void;
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void;
setPaused(sessionId: string, paused: boolean): void;
seek(sessionId: string, seconds: number): void;
setVolume(sessionId: string, volume: number): void;
setAudioTrack(sessionId: string, trackId: number): void;
setSubtitleTrack?(sessionId: string, trackId: number): void;
setSpeed?(sessionId: string, speed: number): void;
setAspect?(sessionId: string, aspect: string): void;
getSessionSnapshot(
sessionId: string
): NativeEmbeddedMpvSessionSnapshot | null;
disposeSession(sessionId: string): void;
}
interface EmbeddedMpvRuntimeSession {
id: string;
title: string;
streamUrl: string;
startedAt: string;
updatedAt: string;
lastPayloadKey: string;
lastStatus: EmbeddedMpvSessionStatus | null;
}
const EMBEDDED_MPV_EXPERIMENT_ENV =
'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT';
function dedupePaths(paths: Array<string | undefined>): string[] {
return [...new Set(paths.filter((value): value is string => Boolean(value)))];
}
export class EmbeddedMpvNativeService {
private addon: NativeEmbeddedMpvAddon | null = null;
private addonLoadError: Error | null = null;
private readonly sessions = new Map<string, EmbeddedMpvRuntimeSession>();
private pollingTimer: NodeJS.Timeout | null = null;
private powerBlockerId: number | null = null;
private readonly loadAddonModule = createRequire(__filename);
private detectCapabilities(): EmbeddedMpvCapabilities {
const addon = this.addon;
return {
subtitles: typeof addon?.setSubtitleTrack === 'function',
playbackSpeed: typeof addon?.setSpeed === 'function',
aspectOverride: typeof addon?.setAspect === 'function',
screenshot: false,
};
}
getSupport(): EmbeddedMpvSupport {
if (process.platform !== 'darwin') {
return {
supported: false,
platform: process.platform,
reason: 'Embedded MPV is currently available on macOS only.',
};
}
if (!this.isEmbeddedMpvEnabled()) {
return {
supported: false,
platform: process.platform,
reason: `Embedded MPV is a macOS-only experimental player. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable it for local development builds.`,
};
}
if (this.addon) {
try {
if (!this.addon.isSupported()) {
return {
supported: false,
platform: process.platform,
reason: 'The native embedded MPV addon reported itself as unsupported.',
};
}
return {
supported: true,
platform: process.platform,
capabilities: this.detectCapabilities(),
};
} catch (error) {
return {
supported: false,
platform: process.platform,
reason:
error instanceof Error
? error.message
: String(error),
};
}
}
if (this.addonLoadError) {
return {
supported: false,
platform: process.platform,
reason: this.addonLoadError.message,
};
}
const candidatePaths = this.getAddonCandidatePaths();
const existingCandidatePath = candidatePaths.find((candidatePath) =>
existsSync(candidatePath)
);
if (!existingCandidatePath) {
return {
supported: false,
platform: process.platform,
reason:
this.readUnavailableReason(candidatePaths) ??
[
'Unable to locate the embedded MPV native addon.',
...candidatePaths.map(
(candidatePath) => `- ${candidatePath}`
),
].join('\n'),
};
}
const missingRuntimeReason =
this.getMissingRuntimeReason(existingCandidatePath);
if (missingRuntimeReason) {
return {
supported: false,
platform: process.platform,
reason: missingRuntimeReason,
};
}
return {
supported: true,
platform: process.platform,
capabilities: this.detectCapabilities(),
};
}
prepareAddon(): EmbeddedMpvSupport {
const support = this.getSupport();
if (!support.supported) {
return support;
}
try {
const addon = this.getAddon();
if (!addon.isSupported()) {
return {
supported: false,
platform: process.platform,
reason: 'The native embedded MPV addon reported itself as unsupported.',
};
}
return {
supported: true,
platform: process.platform,
capabilities: this.detectCapabilities(),
};
} catch (error) {
return {
supported: false,
platform: process.platform,
reason:
error instanceof Error
? error.message
: String(error),
};
}
}
createSession(
bounds: EmbeddedMpvBounds,
title = '',
initialVolume = 1
): EmbeddedMpvSession {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
const windowHandle = this.getMainWindowHandle();
const startedAt = new Date().toISOString();
const sessionId = addon.createSession(
windowHandle,
bounds,
title,
initialVolume
);
this.sessions.set(sessionId, {
id: sessionId,
title,
streamUrl: '',
startedAt,
updatedAt: startedAt,
lastPayloadKey: '',
lastStatus: null,
});
this.ensurePolling();
return this.refreshSession(sessionId) ?? {
id: sessionId,
title,
streamUrl: '',
status: 'idle',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt,
updatedAt: startedAt,
};
}
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
const session = this.getRuntimeSession(sessionId);
session.title = playback.title ?? session.title;
session.streamUrl = playback.streamUrl ?? session.streamUrl;
session.updatedAt = new Date().toISOString();
addon.loadPlayback(sessionId, playback);
this.refreshSession(sessionId);
}
setBounds(sessionId: string, bounds: EmbeddedMpvBounds): void {
this.assertEmbeddedMpvEnabled();
this.getAddon().setBounds(sessionId, bounds);
}
setPaused(sessionId: string, paused: boolean): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setPaused(sessionId, paused);
return this.refreshSession(sessionId);
}
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().seek(sessionId, seconds);
return this.refreshSession(sessionId);
}
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setVolume(sessionId, volume);
return this.refreshSession(sessionId);
}
setAudioTrack(sessionId: string, trackId: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setAudioTrack(sessionId, trackId);
return this.refreshSession(sessionId);
}
setSubtitleTrack(
sessionId: string,
trackId: number
): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSubtitleTrack !== 'function') {
throw new Error(
'Embedded MPV addon does not support subtitle tracks. Rebuild the native addon to enable this feature.'
);
}
addon.setSubtitleTrack(sessionId, trackId);
return this.refreshSession(sessionId);
}
setSpeed(sessionId: string, speed: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setSpeed !== 'function') {
throw new Error(
'Embedded MPV addon does not support playback speed. Rebuild the native addon to enable this feature.'
);
}
addon.setSpeed(sessionId, speed);
return this.refreshSession(sessionId);
}
setAspect(sessionId: string, aspect: string): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
if (typeof addon.setAspect !== 'function') {
throw new Error(
'Embedded MPV addon does not support aspect override. Rebuild the native addon to enable this feature.'
);
}
addon.setAspect(sessionId, aspect);
return this.refreshSession(sessionId);
}
disposeSession(sessionId: string): EmbeddedMpvSession | null {
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
try {
this.getAddon().disposeSession(sessionId);
} finally {
this.sessions.delete(sessionId);
const payload: EmbeddedMpvSession = {
id: session.id,
title: session.title,
streamUrl: session.streamUrl,
status: 'closed',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
};
this.sendSessionUpdate(payload);
this.stopPollingIfIdle();
this.updatePowerBlocker();
return payload;
}
}
shutdown(): void {
const sessionIds = [...this.sessions.keys()];
sessionIds.forEach((sessionId) => this.disposeSession(sessionId));
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
this.pollingTimer = null;
}
this.updatePowerBlocker();
}
private ensurePolling(): void {
if (this.pollingTimer) {
return;
}
this.pollingTimer = setInterval(() => {
[...this.sessions.keys()].forEach((sessionId) => {
this.refreshSession(sessionId);
});
}, 500);
}
private stopPollingIfIdle(): void {
if (this.sessions.size > 0 || !this.pollingTimer) {
return;
}
clearInterval(this.pollingTimer);
this.pollingTimer = null;
}
private refreshSession(sessionId: string): EmbeddedMpvSession | null {
const addon = this.getAddon();
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
const snapshot = addon.getSessionSnapshot(sessionId);
if (!snapshot) {
return null;
}
const payload: EmbeddedMpvSession = {
id: session.id,
title: session.title,
streamUrl: snapshot.streamUrl || session.streamUrl,
status: snapshot.status,
positionSeconds: Math.max(0, Math.floor(snapshot.positionSeconds)),
durationSeconds:
typeof snapshot.durationSeconds === 'number'
? Math.max(0, Math.floor(snapshot.durationSeconds))
: null,
volume: typeof snapshot.volume === 'number' ? snapshot.volume : 1,
audioTracks: Array.isArray(snapshot.audioTracks)
? snapshot.audioTracks
: [],
selectedAudioTrackId:
typeof snapshot.selectedAudioTrackId === 'number'
? snapshot.selectedAudioTrackId
: null,
subtitleTracks: Array.isArray(snapshot.subtitleTracks)
? snapshot.subtitleTracks
: [],
selectedSubtitleTrackId:
typeof snapshot.selectedSubtitleTrackId === 'number'
? snapshot.selectedSubtitleTrackId
: null,
playbackSpeed:
typeof snapshot.playbackSpeed === 'number'
? snapshot.playbackSpeed
: 1,
aspectOverride:
typeof snapshot.aspectOverride === 'string'
? snapshot.aspectOverride
: 'no',
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
...(snapshot.error ? { error: snapshot.error } : {}),
};
session.streamUrl = payload.streamUrl;
session.updatedAt = payload.updatedAt;
session.lastStatus = payload.status;
const nextPayloadKey = JSON.stringify(payload);
if (session.lastPayloadKey !== nextPayloadKey) {
session.lastPayloadKey = nextPayloadKey;
this.sendSessionUpdate(payload);
}
this.updatePowerBlocker();
return payload;
}
private hasPlayingSession(): boolean {
for (const session of this.sessions.values()) {
if (session.lastStatus === 'playing') {
return true;
}
}
return false;
}
private updatePowerBlocker(): void {
const shouldBlock = this.hasPlayingSession();
if (shouldBlock && this.powerBlockerId === null) {
try {
this.powerBlockerId = powerSaveBlocker.start(
'prevent-display-sleep'
);
} catch {
this.powerBlockerId = null;
}
return;
}
if (!shouldBlock && this.powerBlockerId !== null) {
const blockerId = this.powerBlockerId;
this.powerBlockerId = null;
try {
if (powerSaveBlocker.isStarted(blockerId)) {
powerSaveBlocker.stop(blockerId);
}
} catch {
// ignore — assertion will be cleaned up when the process exits
}
}
}
private sendSessionUpdate(session: EmbeddedMpvSession): void {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
return;
}
App.mainWindow.webContents.send(EMBEDDED_MPV_SESSION_UPDATE, session);
}
private getRuntimeSession(sessionId: string): EmbeddedMpvRuntimeSession {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Embedded MPV session "${sessionId}" was not found.`);
}
return session;
}
private getMainWindowHandle(): Buffer {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
throw new Error('The Electron main window is not available.');
}
return App.mainWindow.getNativeWindowHandle();
}
private isExperimentEnabled(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(process.env[EMBEDDED_MPV_EXPERIMENT_ENV] ?? '')
.trim()
.toLowerCase()
);
}
private isEmbeddedMpvEnabled(): boolean {
return app.isPackaged || this.isExperimentEnabled();
}
private assertEmbeddedMpvEnabled(): void {
if (!this.isEmbeddedMpvEnabled()) {
throw new Error(
`Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local macOS harness, or use a packaged macOS build with the bundled runtime.`
);
}
}
private getAddon(): NativeEmbeddedMpvAddon {
if (this.addon) {
return this.addon;
}
if (this.addonLoadError) {
throw this.addonLoadError;
}
const candidatePaths = this.getAddonCandidatePaths();
const existingCandidatePaths = candidatePaths.filter((candidatePath) =>
existsSync(candidatePath)
);
if (existingCandidatePaths.length === 0) {
this.addonLoadError = new Error(
[
'Unable to locate the embedded MPV native addon.',
...candidatePaths.map((candidatePath) => `- ${candidatePath}`),
].join('\n')
);
throw this.addonLoadError;
}
const loadErrors: string[] = [];
for (const candidatePath of existingCandidatePaths) {
try {
this.addon = this.loadAddonModule(
candidatePath
) as NativeEmbeddedMpvAddon;
return this.addon;
} catch (error) {
loadErrors.push(
`${candidatePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
this.addonLoadError = new Error(
[
'Unable to load the embedded MPV native addon.',
...loadErrors.map((error) => `- ${error}`),
].join('\n')
);
throw this.addonLoadError;
}
private getAddonCandidatePaths(): string[] {
const localBuildAddonPath = path.resolve(
process.cwd(),
'apps/electron-backend/native/build/Release/embedded_mpv.node'
);
const distAddonPaths = [
path.resolve(__dirname, 'native/embedded_mpv.node'),
path.resolve(__dirname, '../../native/embedded_mpv.node'),
];
const packagedAddonPaths = [
path.resolve(
(process as NodeJS.Process & { resourcesPath?: string })
.resourcesPath ?? '',
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
),
app.getAppPath()
? path.join(
path.dirname(app.getAppPath()),
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
)
: undefined,
];
return dedupePaths(
app.isPackaged
? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath]
: [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths]
);
}
private readUnavailableReason(candidatePaths: string[]): string | null {
for (const candidatePath of candidatePaths) {
const unavailablePath = path.join(
path.dirname(candidatePath),
'embedded-mpv-unavailable.txt'
);
if (existsSync(unavailablePath)) {
return readFileSync(unavailablePath, 'utf8').trim();
}
}
return null;
}
private getMissingRuntimeReason(addonPath: string): string | null {
const nativeDir = path.dirname(addonPath);
const libMpvPath = path.join(nativeDir, 'lib', 'libmpv.2.dylib');
if (!existsSync(libMpvPath)) {
return `Embedded MPV runtime is incomplete. Missing ${libMpvPath}.`;
}
return null;
}
}
export const embeddedMpvNativeService = new EmbeddedMpvNativeService();
@@ -5,12 +5,14 @@ export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
export const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH';
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
export type StoreType = {
[WINDOW_BOUNDS]: Electron.Rectangle;
[MPV_PLAYER_PATH]: string;
[VLC_PLAYER_PATH]: string;
[MPV_REUSE_INSTANCE]: boolean;
[VLC_REUSE_INSTANCE]: boolean;
};
// Export singleton store instance
@@ -39,6 +39,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_ADD_RECENT_ITEM',
'DB_CLEAR_PLAYLIST_RECENT_ITEMS',
'DB_REMOVE_RECENT_ITEM',
'DB_REMOVE_RECENT_ITEMS_BATCH',
'DB_SAVE_PLAYBACK_POSITION',
'DB_GET_PLAYBACK_POSITION',
'DB_GET_SERIES_PLAYBACK_POSITIONS',
@@ -80,6 +80,10 @@ export async function getWorkerDatabase(): Promise<AppDatabase> {
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('busy_timeout = 5000');
sqlite.pragma('synchronous = NORMAL');
sqlite.pragma('cache_size = -64000');
sqlite.pragma('temp_store = MEMORY');
sqlite.pragma('mmap_size = 268435456');
if (isSqlTraceEnabled()) {
trace('sql-worker', 'open', {
@@ -96,6 +100,12 @@ export function closeWorkerDatabase(): void {
return;
}
try {
sqlite.pragma('optimize');
} catch {
// Optimize is advisory; never block close on it.
}
sqlite.close();
if (isSqlTraceEnabled()) {
@@ -73,6 +73,7 @@ import {
getRecentItems,
getRecentlyViewed,
removeRecentItem,
removeRecentItemsBatch,
} from '../database/operations/recently-viewed.operations';
import {
deleteXtreamContent,
@@ -761,6 +762,13 @@ async function executeRequest(message: DbWorkerRequestMessage) {
return removeRecentItem(db, payload.contentId, payload.playlistId);
}
case 'DB_REMOVE_RECENT_ITEMS_BATCH': {
const payload = message.payload as {
items: { contentId: number; playlistId: string }[];
};
return removeRecentItemsBatch(db, payload.items);
}
case 'DB_SAVE_PLAYBACK_POSITION': {
const payload = message.payload as {
playlistId: string;
+42 -2
View File
@@ -9,6 +9,9 @@ import {
setMainWindow as setDownloadsMainWindow,
} from './app/events/database/downloads.events';
import ElectronEvents from './app/events/electron.events';
import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
} from './app/events/embedded-mpv.events';
import EpgEvents from './app/events/epg.events';
import PlayerEvents from './app/events/player.events';
import PlaylistEvents from './app/events/playlist.events';
@@ -29,6 +32,34 @@ if (electronUserDataPath) {
app.setPath('userData', electronUserDataPath);
}
let fixPathScheduled = false;
/**
* Update process.env.PATH from the user's interactive login shell so that
* spawned external players (MPV/VLC) can be resolved by binary name.
*
* Runs after window creation + IPC handler registration so the 50-300 ms
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
* subsequent calls are no-ops.
*/
function scheduleDeferredFixPath(): void {
if (fixPathScheduled || process.platform === 'win32') {
return;
}
fixPathScheduled = true;
setImmediate(() => {
try {
fixPath();
if (isStartupTraceEnabled()) {
trace('startup', 'fix-path:done');
}
} catch (error) {
console.warn('fix-path failed:', error);
}
});
}
export default class Main {
static initialize() {
if (SquirrelEvents.handleEvents()) {
@@ -57,6 +88,7 @@ export default class Main {
}
ElectronEvents.bootstrapElectronEvents();
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
PlaylistEvents.bootstrapPlaylistEvents();
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
@@ -85,11 +117,18 @@ export default class Main {
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done');
}
// Hydrate process.env.PATH from the user's login shell now — after
// the window has loaded and IPC handlers are live. Fire-and-forget
// (setImmediate) so it doesn't gate any user-visible work. Worst
// case: the user clicks an external player within the ~100 ms it
// takes to complete; the spawn would still find MPV/VLC at any of
// the well-known paths checked by getDefault*Path before falling
// back to bare-name PATH lookup.
scheduleDeferredFixPath();
}
}
fixPath();
// handle setup events as quickly as possible
Main.initialize();
@@ -105,5 +144,6 @@ app.whenReady().then(async () => {
});
app.on('before-quit', () => {
shutdownEmbeddedMpv();
void databaseWorkerClient.shutdown();
});
@@ -22,6 +22,18 @@ export interface RawChannel {
xmltv_id: string;
}
export interface RawRadioStation {
id: string;
name: string;
o_name: string;
cmd: string;
logo: string;
category_id: string;
tv_genre_id: string;
number: string;
radio: true;
}
export interface RawVodItem {
id: string;
name: string;
@@ -102,7 +114,9 @@ export interface GeneratedPortalData {
itvCategories: RawCategory[];
vodCategories: RawCategory[];
seriesCategories: RawCategory[];
radioCategories: RawCategory[];
channels: Map<string, RawChannel[]>; // categoryId -> channels
radio: Map<string, RawRadioStation[]>; // categoryId -> radio stations
vod: Map<string, RawVodItem[]>; // categoryId -> items
series: Map<string, RawSeriesItem[]>; // categoryId -> items
seasons: Map<string, RawSeason[]>; // seriesItemId -> seasons
@@ -146,7 +160,9 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
itvCategories: [],
vodCategories: [],
seriesCategories: [],
radioCategories: [],
channels: new Map(),
radio: new Map(),
vod: new Map(),
series: new Map(),
seasons: new Map(),
@@ -165,6 +181,22 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
channelIndex += config.itemsPerCategory;
}
// ------ Radio categories + stations ------
data.radioCategories = generateCategories(
'radio',
config.categoryCount.radio
);
let radioIndex = 0;
for (const cat of data.radioCategories) {
const stations = generateRadioStations(
cat.id,
config.itemsPerCategory,
radioIndex
);
data.radio.set(cat.id, stations);
radioIndex += config.itemsPerCategory;
}
// ------ VOD categories + items ------
data.vodCategories = generateCategories('vod', config.categoryCount.vod);
let vodIndex = 0;
@@ -223,16 +255,35 @@ const SERIES_GENRE_NAMES = [
'Thriller Series', 'Horror Series', 'Western Series',
];
function getGenreNames(type: 'itv' | 'vod' | 'series'): string[] {
const RADIO_GENRE_NAMES = [
'News Radio', 'Talk Radio', 'Jazz', 'Classical', 'Rock',
'Pop Hits', 'Electronic', 'World Music', 'Sports Radio', 'Public Radio',
'Local Stations', 'Latin', 'Hip Hop', 'Ambient', 'Oldies',
'Country', 'Reggae', 'Soul', 'Dance', 'Weather',
];
function getGenreNames(type: 'itv' | 'vod' | 'series' | 'radio'): string[] {
if (type === 'itv') return ITV_GENRE_NAMES;
if (type === 'vod') return VOD_GENRE_NAMES;
if (type === 'radio') return RADIO_GENRE_NAMES;
return SERIES_GENRE_NAMES;
}
function generateCategories(type: 'itv' | 'vod' | 'series', count: number): RawCategory[] {
function generateCategories(
type: 'itv' | 'vod' | 'series' | 'radio',
count: number
): RawCategory[] {
const names = getGenreNames(type);
return Array.from({ length: count }, (_, i) => {
const id = String((type === 'itv' ? 1000 : type === 'vod' ? 2000 : 3000) + i + 1);
const idBase =
type === 'itv'
? 1000
: type === 'vod'
? 2000
: type === 'series'
? 3000
: 4000;
const id = String(idBase + i + 1);
const title = names[i % names.length];
return { id, title, alias: title.toLowerCase().replace(/\s+/g, '_') };
});
@@ -260,6 +311,29 @@ function generateChannels(categoryId: string, count: number, startIndex: number)
});
}
function generateRadioStations(
categoryId: string,
count: number,
startIndex: number
): RawRadioStation[] {
return Array.from({ length: count }, (_, i) => {
const globalIndex = startIndex + i;
const id = String(40000 + globalIndex);
const name = `${faker.music.genre()} ${faker.word.noun()} Radio`;
return {
id,
name,
o_name: name,
cmd: `ffrt4://radio/${id}/index.mp3`,
logo: logoUrl(`radio-${id}`),
category_id: categoryId,
tv_genre_id: categoryId,
number: String(globalIndex + 1),
radio: true,
};
});
}
// ---------------------------------------------------------------------------
// VOD generators
// ---------------------------------------------------------------------------
@@ -7,7 +7,7 @@ import { extractMac } from './get-categories.handler.js';
*
* Query params:
* cmd: the ffrt4:// or similar command from the content item
* type: itv | vod | series
* type: itv | vod | series | radio
*/
export function handleCreateLink(req: Request, res: Response): void {
const mac = extractMac(req);
@@ -6,7 +6,12 @@ import {
removeFavorite,
} from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
import {
RawChannel,
RawRadioStation,
RawSeriesItem,
RawVodItem,
} from '../data-generator.js';
/**
* Stalker favorites endpoint.
@@ -15,7 +20,7 @@ import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
* action: favorites
* fav_action: get | add | remove (some portals use set/unset)
* item_id: the item id to add/remove
* type: itv | vod | series
* type: itv | vod | series | radio
*/
export function handleFavorites(req: Request, res: Response): void {
const mac = extractMac(req);
@@ -38,7 +43,7 @@ export function handleFavorites(req: Request, res: Response): void {
const favIds = getFavorites(mac);
const data = getPortalData(mac);
type AnyItem = RawChannel | RawVodItem | RawSeriesItem;
type AnyItem = RawChannel | RawRadioStation | RawVodItem | RawSeriesItem;
const result: AnyItem[] = [];
for (const id of favIds) {
@@ -58,11 +63,15 @@ export function handleFavorites(req: Request, res: Response): void {
function findItemById(
data: ReturnType<typeof getPortalData>,
id: string
): RawChannel | RawVodItem | RawSeriesItem | undefined {
): RawChannel | RawRadioStation | RawVodItem | RawSeriesItem | undefined {
for (const items of data.channels.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
}
for (const items of data.radio.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
}
for (const items of data.vod.values()) {
const found = items.find((i) => i.id === id);
if (found) return found;
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
/**
* Stalker get_categories — returns category list filtered by type (itv/vod/series).
* Stalker get_categories — returns category list filtered by type.
*/
export function handleGetCategories(req: Request, res: Response): void {
const mac = extractMac(req);
@@ -12,6 +12,8 @@ export function handleGetCategories(req: Request, res: Response): void {
let categories;
if (type === 'itv') {
categories = data.itvCategories;
} else if (type === 'radio') {
categories = data.radioCategories;
} else if (type === 'series') {
categories = data.seriesCategories;
} else {
@@ -14,6 +14,8 @@ export function handleGetGenres(req: Request, res: Response): void {
let categories;
if (type === 'itv') {
categories = data.itvCategories;
} else if (type === 'radio') {
categories = data.radioCategories;
} else if (type === 'series') {
categories = data.seriesCategories;
} else {
@@ -1,17 +1,22 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
import { extractMac } from './get-categories.handler.js';
import { RawChannel, RawSeriesItem, RawVodItem } from '../data-generator.js';
import {
RawChannel,
RawRadioStation,
RawSeriesItem,
RawVodItem,
} from '../data-generator.js';
const PAGE_SIZE = 14;
type AnyItem = RawChannel | RawVodItem | RawSeriesItem;
type AnyItem = RawChannel | RawRadioStation | RawVodItem | RawSeriesItem;
/**
* Stalker get_ordered_list — returns paginated content for a category.
*
* Query params:
* type: itv | vod | series
* type: itv | vod | series | radio
* category: category_id (or "*" for all)
* genre: same as category for itv
* p: page number (1-based)
@@ -35,6 +40,14 @@ export function handleGetOrderedList(req: Request, res: Response): void {
} else {
allItems = data.channels.get(categoryId) ?? [];
}
} else if (type === 'radio') {
if (categoryId === '*') {
for (const items of data.radio.values()) {
allItems.push(...items);
}
} else {
allItems = data.radio.get(categoryId) ?? [];
}
} else if (type === 'series') {
if (categoryId === '*') {
for (const items of data.series.values()) {
@@ -4,6 +4,7 @@ export interface ScenarioConfig {
seed: number;
categoryCount: {
itv: number;
radio: number;
vod: number;
series: number;
};
@@ -25,7 +26,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'default',
description: 'Balanced portal — 8 categories, 40 items each',
seed: 1001,
categoryCount: { itv: 8, vod: 8, series: 8 },
categoryCount: { itv: 8, radio: 8, vod: 8, series: 8 },
itemsPerCategory: 40,
seasonsPerSeries: 3,
episodesPerSeason: 8,
@@ -36,7 +37,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'large',
description: 'Large catalog — 20 categories, 200 items each',
seed: 9999,
categoryCount: { itv: 20, vod: 20, series: 20 },
categoryCount: { itv: 20, radio: 20, vod: 20, series: 20 },
itemsPerCategory: 200,
seasonsPerSeries: 5,
episodesPerSeason: 12,
@@ -47,7 +48,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'series-heavy',
description: 'Series-heavy portal — many series with deep seasons',
seed: 2002,
categoryCount: { itv: 3, vod: 5, series: 15 },
categoryCount: { itv: 3, radio: 3, vod: 5, series: 15 },
itemsPerCategory: 30,
seasonsPerSeries: 6,
episodesPerSeason: 10,
@@ -58,7 +59,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'minimal',
description: 'Minimal portal — 2 categories, 5 items (edge case testing)',
seed: 3003,
categoryCount: { itv: 2, vod: 2, series: 2 },
categoryCount: { itv: 2, radio: 2, vod: 2, series: 2 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 3,
@@ -69,7 +70,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'is-series',
description: 'VOD with is_series=1 flag (Ministra plugin flow testing)',
seed: 4004,
categoryCount: { itv: 4, vod: 6, series: 4 },
categoryCount: { itv: 4, radio: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 3,
episodesPerSeason: 6,
@@ -80,7 +81,7 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
name: 'embedded-series',
description: 'VOD with embedded series[] arrays',
seed: 5005,
categoryCount: { itv: 4, vod: 6, series: 4 },
categoryCount: { itv: 4, radio: 4, vod: 6, series: 4 },
itemsPerCategory: 20,
seasonsPerSeries: 2,
episodesPerSeason: 5,
@@ -111,7 +112,7 @@ export function getScenario(mac: string): ScenarioConfig {
name: 'auto',
description: `Auto-generated from MAC ${mac}`,
seed: macToSeed(mac),
categoryCount: { itv: 6, vod: 6, series: 6 },
categoryCount: { itv: 6, radio: 6, vod: 6, series: 6 },
itemsPerCategory: 30,
seasonsPerSeries: 3,
episodesPerSeason: 8,
+114 -4
View File
@@ -1,4 +1,10 @@
import { expect, Locator, Page, test } from '@playwright/test';
import {
expect,
type APIRequestContext,
type Locator,
type Page,
test,
} from '@playwright/test';
/**
* Stalker Portal E2E Tests
@@ -8,7 +14,7 @@ import { expect, Locator, Page, test } from '@playwright/test';
* dev server when running e2e tests (see playwright.config.ts).
*
* Default scenario MAC (00:1A:79:00:00:01) provides:
* - 8 categories per content type (VOD / Series / ITV)
* - 8 categories per content type (VOD / Series / ITV / Radio)
* - 40 items per category
* - 3 seasons × 8 episodes per series item
*
@@ -62,6 +68,31 @@ async function setInputValue(input: Locator, value: string): Promise<void> {
await expect(input).toHaveValue(value);
}
async function resetMockServer(request: APIRequestContext): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const response = await request.post(`${MOCK_SERVER}/reset`);
if (response.ok()) {
return;
}
lastError = new Error(
`Reset failed with status ${response.status()}`
);
} catch (error) {
lastError = error;
}
await new Promise((resolve) =>
setTimeout(resolve, 250 * (attempt + 1))
);
}
throw lastError;
}
/**
* Add a Stalker portal via the UI:
* 1. Click the "add playlist" button to open the unified dialog
@@ -97,7 +128,7 @@ async function addStalkerPortal(
test.beforeEach(async ({ page, request }) => {
// Reset mock server state (clears in-memory favorites and cache)
await request.post(`${MOCK_SERVER}/reset`);
await resetMockServer(request);
// Playwright creates a fresh browser context per test, so extra
// IndexedDB cleanup here only risks racing with app-managed DB handles.
@@ -201,9 +232,63 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => {
await expect(page.locator('app-epg-list')).toBeVisible({
timeout: 20_000,
});
await expect(page.locator('app-epg-list .selected-date')).toBeVisible({
await expect(
page.locator(
'app-live-epg-panel .selected-date, app-epg-list .selected-date'
)
).toBeVisible({ timeout: 20_000 });
});
test('@stalker radio — stations use the inline audio player without EPG', async ({
page,
}) => {
await addStalkerPortal(page);
const radioListRequests: string[] = [];
const epgRequests: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (
url.includes('type=radio') &&
url.includes('action=get_ordered_list')
) {
radioListRequests.push(url);
}
if (
url.includes('action=get_epg_info') ||
url.includes('action=get_short_epg')
) {
epgRequests.push(url);
}
});
await page.getByRole('link', { name: /radio/i }).click();
await page.waitForURL(/stalker.*radio/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const stations = page.locator('[data-test-id="channel-item"]');
await expect(stations.first()).toBeVisible({ timeout: 20_000 });
await expect.poll(() => radioListRequests.length).toBeGreaterThan(0);
await stations.first().click();
await expect(stations.first()).toHaveClass(/active/, { timeout: 20_000 });
await expect(page.locator('app-audio-player')).toBeVisible({
timeout: 20_000,
});
await page.getByRole('button', { name: 'Hide channels list' }).click();
const restoreButton = page.getByRole('button', {
name: 'Show channels list',
});
await expect(restoreButton).toBeVisible();
await restoreButton.click();
await expect(stations.first()).toBeVisible();
await expect(page.locator('app-epg-list')).toHaveCount(0);
expect(epgRequests).toHaveLength(0);
});
test('@stalker bulk EPG is fetched once and reused across channel switches', async ({
@@ -282,6 +367,31 @@ test('@stalker create_link returns a playable stream URL', async ({
expect(streamUrl).toMatch(/\.m3u8$/);
});
test('@stalker mock server returns radio categories and stations', async ({
request,
}) => {
const categoriesResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_categories&type=radio&macAddress=${DEFAULT_MAC}`
);
expect(categoriesResponse.ok()).toBeTruthy();
const categoriesBody = await categoriesResponse.json();
expect(categoriesBody.payload.js.length).toBeGreaterThan(0);
const firstCategory = categoriesBody.payload.js[0].id;
const stationsResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=radio&category=${firstCategory}&p=1&macAddress=${DEFAULT_MAC}&JsHttpRequest=1-xml`
);
expect(stationsResponse.ok()).toBeTruthy();
const stationsBody = await stationsResponse.json();
const firstStation = stationsBody.payload.js.data[0];
expect(firstStation).toEqual(
expect.objectContaining({
category_id: firstCategory,
radio: true,
})
);
});
test('@stalker series — seasons load for a series item', async ({
request,
}) => {
+32
View File
@@ -0,0 +1,32 @@
// Cover size tokens — consumed by all cover/poster grids and rails.
// Toggled via [data-cover-size] attribute on <html>, set from SettingsStore.
:root {
--cover-grid-min-width: 148px;
--cover-rail-width: 172px;
--cover-gap: 16px;
}
:root[data-cover-size='small'] {
--cover-grid-min-width: 120px;
--cover-rail-width: 140px;
--cover-gap: 12px;
}
:root[data-cover-size='medium'] {
--cover-grid-min-width: 148px;
--cover-rail-width: 172px;
--cover-gap: 16px;
}
:root[data-cover-size='large'] {
--cover-grid-min-width: 200px;
--cover-rail-width: 220px;
--cover-gap: 20px;
}
@media (max-width: 600px) {
:root[data-cover-size='large'] {
--cover-grid-min-width: 160px;
--cover-rail-width: 170px;
}
}
+2
View File
@@ -36,6 +36,7 @@ const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
epgUrl: [],
streamFormat: StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
@@ -45,6 +46,7 @@ const DEFAULT_SETTINGS: Settings = {
mpvPlayerPath: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
downloadFolder: '',
+23 -3
View File
@@ -1,4 +1,4 @@
import { Component, HostBinding, inject, OnInit } from '@angular/core';
import { Component, effect, HostBinding, inject, OnInit } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
@@ -9,7 +9,7 @@ import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { filter, take } from 'rxjs';
import { DataService } from 'services';
import { DataService, SettingsStore } from 'services';
import {
AUTO_UPDATE_PLAYLISTS,
Language,
@@ -39,6 +39,7 @@ export class AppComponent implements OnInit {
private store = inject(Store);
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
private settingsStore = inject(SettingsStore);
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
/** Default language as fallback */
@@ -64,6 +65,11 @@ export class AppComponent implements OnInit {
});
}
}
effect(() => {
const size = this.settingsStore.coverSize?.() ?? 'medium';
document.documentElement.dataset.coverSize = size;
});
if (window.electron) {
document.addEventListener('keydown', (event) => {
if (event.ctrlKey || event.metaKey) {
@@ -100,7 +106,21 @@ export class AppComponent implements OnInit {
// Settings are stored in IndexedDB and loaded by the settings store
// Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component
this.translate.use(settings.language ?? this.DEFAULT_LANG);
const resolvedLang =
settings.language ?? this.DEFAULT_LANG;
this.translate.use(resolvedLang);
// Mirror the active language to localStorage so the next
// cold start can read it synchronously in app.config.ts's
// getInitialLanguage() and avoid the English-then-localized
// flash for non-English users.
try {
localStorage.setItem(
'iptvnator:preferred-language',
resolvedLang
);
} catch {
// Ignore quota / privacy mode errors.
}
// Fetch EPG if URLs are configured (only fetch stale data)
if (
+44 -7
View File
@@ -47,6 +47,48 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
}
/**
* Synchronously read the user's preferred language from localStorage so the
* very first render uses the right locale instead of flashing English first
* and re-rendering once the IDB-backed settings finish loading.
*
* The hint is written by AppComponent.initSettings() whenever the saved
* settings resolve, so first-ever boots fall back to English (no hint
* present yet) and every subsequent boot uses the saved language.
*/
const SUPPORTED_LANGS = new Set([
'ar',
'ary',
'by',
'de',
'el',
'en',
'es',
'fr',
'it',
'ja',
'ko',
'nl',
'pl',
'pt',
'ru',
'tr',
'zh',
'zhtw',
]);
export const PREFERRED_LANGUAGE_STORAGE_KEY = 'iptvnator:preferred-language';
export function getInitialLanguage(): string {
try {
const saved = localStorage.getItem(PREFERRED_LANGUAGE_STORAGE_KEY);
if (saved && SUPPORTED_LANGS.has(saved)) {
return saved;
}
} catch {
// localStorage may throw in privacy modes; fall through to default.
}
return 'en';
}
/**
* Conditionally provides the necessary service based on the current environment
*/
@@ -69,24 +111,19 @@ export const appConfig: ApplicationConfig = {
}),
provideEffects([PlaylistEffects]),
provideRouterStore(),
provideStoreDevtools({
maxAge: 25,
logOnly: AppConfig.production,
}),
...(AppConfig.production ? [] : [provideStoreDevtools({ maxAge: 25 })]),
provideServiceWorker('ngsw-worker.js', {
enabled: AppConfig.production && !!window.electron,
registrationStrategy: 'registerWhenStable:30000',
}),
importProvidersFrom(
AppConfig.environment === 'WEB'
? NgxIndexedDBModule.forRoot(dbConfig)
: [],
NgxIndexedDBModule.forRoot(dbConfig),
NgxSkeletonLoaderModule.forRoot({
animation: 'pulse',
loadingText: 'This item is actually loading...',
}),
TranslateModule.forRoot({
defaultLanguage: getInitialLanguage(),
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
+49 -13
View File
@@ -2,6 +2,7 @@ describe('app routes', () => {
let workspaceChildren: Array<{
canActivate?: unknown[];
data?: Record<string, unknown>;
loadChildren?: unknown;
loadComponent?: unknown;
path?: string;
redirectTo?: unknown;
@@ -9,25 +10,61 @@ describe('app routes', () => {
beforeAll(async () => {
jest.resetModules();
jest.unstable_mockModule('@iptvnator/playlist/m3u/feature-player', () => ({
M3uCollectionRouteComponent: class {},
VideoPlayerComponent: class {},
provideM3uWorkspaceRouteSession: () => [],
}));
jest.unstable_mockModule(
'@iptvnator/playlist/m3u/feature-player',
() => ({
createM3uWorkspaceRoutes: () => [
{
path: '',
pathMatch: 'full',
redirectTo: 'all',
},
{
path: 'favorites',
data: {
mode: 'favorites',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: 'recent',
data: {
mode: 'recent',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: ':view',
},
],
})
);
const { routes } = await import('./app.routes');
const workspaceRoute = routes.find((route) => route.path === 'workspace');
const workspaceRoute = routes.find(
(route) => route.path === 'workspace'
);
workspaceChildren = workspaceRoute?.children ?? [];
});
it('routes M3U favorites and recent pages through the shared collection wrapper', async () => {
const favoritesRoute = workspaceChildren.find(
(route) => route.path === 'playlists/:id/favorites'
it('lazy-loads M3U workspace routes through the feature boundary', async () => {
const playlistRoute = workspaceChildren.find(
(route) => route.path === 'playlists/:id'
);
const recentRoute = workspaceChildren.find(
(route) => route.path === 'playlists/:id/recent'
const loadChildren = playlistRoute?.loadChildren as
| (() => Promise<typeof workspaceChildren>)
| undefined;
const m3uRoutes = (await loadChildren?.()) ?? [];
const defaultRoute = m3uRoutes.find((route) => route.path === '');
const favoritesRoute = m3uRoutes.find(
(route) => route.path === 'favorites'
);
const recentRoute = m3uRoutes.find((route) => route.path === 'recent');
const playerRoute = m3uRoutes.find((route) => route.path === ':view');
expect(defaultRoute?.redirectTo).toBe('all');
expect(favoritesRoute?.data).toEqual({
mode: 'favorites',
portalType: 'm3u',
@@ -39,8 +76,7 @@ describe('app routes', () => {
defaultScope: 'playlist',
});
expect(typeof favoritesRoute?.loadComponent).toBe('function');
expect(typeof recentRoute?.loadComponent).toBe('function');
expect(playerRoute).toBeDefined();
});
it('adds a shared /workspace/global-recent route', async () => {
+3 -36
View File
@@ -1,6 +1,5 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { provideM3uWorkspaceRouteSession } from '@iptvnator/playlist/m3u/feature-player';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
const workspaceEntryRedirect = async () =>
@@ -54,41 +53,9 @@ export const routes: Routes = [
},
{
path: 'playlists/:id',
pathMatch: 'full',
redirectTo: 'playlists/:id/all',
},
{
path: 'playlists/:id/favorites',
providers: provideM3uWorkspaceRouteSession(),
loadComponent: () =>
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.M3uCollectionRouteComponent
),
data: {
mode: 'favorites',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: 'playlists/:id/recent',
providers: provideM3uWorkspaceRouteSession(),
loadComponent: () =>
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.M3uCollectionRouteComponent
),
data: {
mode: 'recent',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: 'playlists/:id/:view',
providers: provideM3uWorkspaceRouteSession(),
loadComponent: () =>
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.VideoPlayerComponent
loadChildren: () =>
import('@iptvnator/playlist/m3u/feature-player').then((m) =>
m.createM3uWorkspaceRoutes()
),
},
{
@@ -1,9 +1,16 @@
import {
ChangeDetectionStrategy,
Component,
EnvironmentInjector,
OnDestroy,
Type,
ViewContainerRef,
effect,
computed,
inject,
input,
output,
untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -14,14 +21,24 @@ import {
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
} from '@iptvnator/portal/shared/ui';
import { CollectionScope, PortalProvider } from '@iptvnator/portal/shared/util';
import { StalkerCollectionDetailComponent } from '@iptvnator/portal/stalker/feature';
import { XtreamCollectionDetailComponent } from '@iptvnator/portal/xtream/feature';
import {
CollectionScope,
PortalProvider,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
type UnifiedPortalType = 'm3u' | 'xtream' | 'stalker';
type CollectionMode = 'favorites' | 'recent';
interface ComponentOutputRef {
subscribe(callback: () => void): { unsubscribe(): void };
}
interface CollectionDetailInstance {
closeRequested?: ComponentOutputRef;
}
const EMPTY_STATE_BY_MODE: Record<
CollectionMode,
{ icon: string; titleKey: string; descriptionKey: string }
@@ -44,14 +61,100 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
return 'stalker';
}
@Component({
selector: 'app-global-collection-detail-host',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalCollectionDetailHostComponent implements OnDestroy {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly closeRequested = output<void>();
private readonly viewContainer = inject(ViewContainerRef);
private readonly environmentInjector = inject(EnvironmentInjector);
private closeSubscription: { unsubscribe(): void } | null = null;
private renderRequestId = 0;
constructor() {
effect(() => {
const item = this.item();
untracked(() => {
void this.renderDetail(item);
});
});
}
ngOnDestroy(): void {
this.renderRequestId++;
this.clearDetail();
}
private async renderDetail(
item: UnifiedCollectionItem | null
): Promise<void> {
const requestId = ++this.renderRequestId;
this.clearDetail();
if (!item) {
return;
}
const componentType = await this.loadDetailComponent(item.sourceType);
if (requestId !== this.renderRequestId || !componentType) {
return;
}
const componentRef = this.viewContainer.createComponent(componentType, {
environmentInjector: this.environmentInjector,
});
componentRef.setInput('item', item);
this.subscribeToClose(componentRef.instance);
}
private async loadDetailComponent(
sourceType: UnifiedCollectionItem['sourceType']
): Promise<Type<unknown> | null> {
if (sourceType === 'xtream') {
const component = await import('@iptvnator/portal/xtream/feature');
return component.XtreamCollectionDetailComponent;
}
if (sourceType === 'stalker') {
const component = await import('@iptvnator/portal/stalker/feature');
return component.StalkerCollectionDetailComponent;
}
return null;
}
private subscribeToClose(instance: unknown): void {
const closeRequested = (instance as CollectionDetailInstance)
.closeRequested;
if (!closeRequested) {
return;
}
this.closeSubscription = closeRequested.subscribe(() => {
this.closeRequested.emit();
});
}
private clearDetail(): void {
this.closeSubscription?.unsubscribe();
this.closeSubscription = null;
this.viewContainer.clear();
}
}
@Component({
selector: 'app-global-collection-route',
imports: [
EmptyStateComponent,
StalkerCollectionDetailComponent,
GlobalCollectionDetailHostComponent,
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
XtreamCollectionDetailComponent,
],
template: `
@if (hasNoPlaylists()) {
@@ -74,9 +177,9 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
>
<ng-template unifiedCollectionDetail let-item let-close="close">
@if (item.sourceType === 'xtream') {
<app-xtream-collection-detail [item]="item" />
<app-global-collection-detail-host [item]="item" />
} @else if (item.sourceType === 'stalker') {
<app-stalker-collection-detail
<app-global-collection-detail-host
[item]="item"
(closeRequested)="close()"
/>
@@ -96,7 +199,9 @@ export class GlobalCollectionRouteComponent {
readonly mode = input<CollectionMode>('favorites');
readonly defaultScope = input<CollectionScope | undefined>(undefined);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
readonly emptyState = computed(() => EMPTY_STATE_BY_MODE[this.mode()]);
@@ -108,8 +213,11 @@ export class GlobalCollectionRouteComponent {
const provider = this.playlistContext.activeProvider();
return provider ? providerToPortalType(provider) : null;
});
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(() =>
this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all')
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(
() =>
this.activePlaylistId()
? 'playlist'
: (this.defaultScope() ?? 'all')
);
addPlaylist(): void {
+26 -13
View File
@@ -42,6 +42,7 @@ interface ErrorStatus {
})
export class ElectronService extends DataService {
private eventListeners: { [key: string]: () => void } = {};
private messageListeners = new Map<string, EventListener>();
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translateService = inject(TranslateService);
@@ -556,27 +557,39 @@ export class ElectronService extends DataService {
unsubscribe()
);
this.eventListeners = {};
} else if (this.eventListeners[type]) {
// Remove all tracked window message listeners
this.messageListeners.forEach((listener) =>
window.removeEventListener('message', listener)
);
this.messageListeners.clear();
return;
}
if (this.eventListeners[type]) {
// Unsubscribe from a specific event
this.eventListeners[type]();
delete this.eventListeners[type];
}
// Also remove any window message listeners
window.removeEventListener('message', this.getListenerForCommand(type));
}
private getListenerForCommand(_command: string): EventListener {
void _command;
// This is a placeholder. In a real implementation, you would need to
// store the actual listener functions to be able to remove them
return () => undefined;
// Remove the window message listener registered for this command
const messageListener = this.messageListeners.get(type);
if (messageListener) {
window.removeEventListener('message', messageListener);
this.messageListeners.delete(type);
}
}
listenOn(command: string, callback: (...args: unknown[]) => void): void {
// For Electron, use window message events
void command;
window.addEventListener('message', callback);
// Drop any existing listener for this command so calling listenOn()
// again rebinds rather than accumulating duplicates.
const existing = this.messageListeners.get(command);
if (existing) {
window.removeEventListener('message', existing);
}
const listener = callback as EventListener;
window.addEventListener('message', listener);
this.messageListeners.set(command, listener);
}
getAppEnvironment(): string {
@@ -47,6 +47,7 @@ describe('PlayerService', () => {
expect(service.isEmbeddedPlayer(VideoPlayer.VideoJs)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.Html5Player)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.ArtPlayer)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.EmbeddedMpv)).toBe(true);
expect(service.isEmbeddedPlayer(VideoPlayer.MPV)).toBe(false);
expect(service.isEmbeddedPlayer(VideoPlayer.VLC)).toBe(false);
});
@@ -57,7 +58,19 @@ describe('PlayerService', () => {
title: 'Example Video',
});
expect(dialog.open).toHaveBeenCalled();
expect(dialog.open).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
data: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
playback: expect.objectContaining({
streamUrl: 'https://example.com/video.mp4',
title: 'Example Video',
}),
}),
})
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
+9 -2
View File
@@ -26,7 +26,8 @@ export class PlayerService {
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer
player === VideoPlayer.ArtPlayer ||
player === VideoPlayer.EmbeddedMpv
);
}
@@ -118,7 +119,13 @@ export class PlayerService {
return import('@iptvnator/portal/xtream/feature').then(
({ PlayerDialogComponent }) => {
this.dialog.open(PlayerDialogComponent, {
data: { streamUrl, title, contentInfo, startTime },
data: {
streamUrl,
title,
contentInfo,
startTime,
playback,
},
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
+26 -4
View File
@@ -52,6 +52,7 @@ interface ErrorStatus {
providedIn: 'root',
})
export class PwaService extends DataService {
private messageListeners = new Map<string, EventListener>();
private readonly http = inject(HttpClient);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
@@ -500,12 +501,33 @@ export class PwaService extends DataService {
});
}
removeAllListeners(): void {
// not implemented
removeAllListeners(type: string): void {
if (type === 'all') {
this.messageListeners.forEach((listener) =>
window.removeEventListener('message', listener)
);
this.messageListeners.clear();
return;
}
const messageListener = this.messageListeners.get(type);
if (messageListener) {
window.removeEventListener('message', messageListener);
this.messageListeners.delete(type);
}
}
listenOn(_command: string, callback: (...args: unknown[]) => void): void {
window.addEventListener('message', callback);
listenOn(command: string, callback: (...args: unknown[]) => void): void {
// Drop any existing listener for this command so calling listenOn()
// again rebinds rather than accumulating duplicates.
const existing = this.messageListeners.get(command);
if (existing) {
window.removeEventListener('message', existing);
}
const listener = callback as EventListener;
window.addEventListener('message', listener);
this.messageListeners.set(command, listener);
}
getAppEnvironment(): string {
@@ -1,6 +1,7 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData,
@@ -67,5 +68,9 @@ export function provideWorkspaceShellActions(): Provider[] {
provide: WORKSPACE_SHELL_ACTIONS,
useExisting: AppWorkspaceShellActionsService,
},
{
provide: PORTAL_SHELL_ACTIONS,
useExisting: AppWorkspaceShellActionsService,
},
];
}
@@ -0,0 +1,179 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'about'"
id="about"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>info</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.ABOUT' | translate }}</h3>
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
</div>
</header>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control version-block">
<span>{{ version() }}</span>
<small>{{ updateMessage() }}</small>
</div>
</div>
@if (isDesktop()) {
<div class="settings-note">
<strong>{{
'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate
}}</strong>
<span>{{
'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate
}}</span>
</div>
}
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
<p>{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<div class="link-cards">
<a
class="link-card link-card--github"
href="https://github.com/4gray/iptvnator"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-github"
>
<span class="link-card__glyph">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.112.793-.26.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z"
/>
</svg>
</span>
<span class="link-card__text">
<strong>GitHub</strong>
<span>{{
'SETTINGS.LINK_GITHUB_DESCRIPTION' | translate
}}</span>
</span>
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
</a>
<a
class="link-card link-card--blog"
href="https://4gray.github.io/iptvnator/"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-blog"
>
<span class="link-card__glyph">
<mat-icon>menu_book</mat-icon>
</span>
<span class="link-card__text">
<strong>{{ 'SETTINGS.LINK_BLOG' | translate }}</strong>
<span>{{
'SETTINGS.LINK_BLOG_DESCRIPTION' | translate
}}</span>
</span>
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
</a>
<a
class="link-card link-card--telegram"
href="https://t.me/iptvnator"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-telegram"
>
<span class="link-card__glyph">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"
/>
</svg>
</span>
<span class="link-card__text">
<strong>Telegram</strong>
<span>{{
'SETTINGS.LINK_TELEGRAM_DESCRIPTION' | translate
}}</span>
</span>
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
</a>
</div>
</div>
</div>
<div class="setting-item setting-item--full-control">
<div class="support-card" data-test-id="support-card">
<div class="support-card__intro">
<div class="support-card__emblem">
<mat-icon>favorite</mat-icon>
</div>
<div class="support-card__copy">
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
</div>
</div>
<div class="support-card__actions">
<a
class="support-btn support-btn--sponsors"
href="https://github.com/sponsors/4gray"
target="_blank"
rel="noreferrer noopener"
data-test-id="support-sponsors"
>
<span class="support-btn__glyph">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</span>
<span class="support-btn__text">
<strong>GitHub Sponsors</strong>
<small>{{
'SETTINGS.SUPPORT_SPONSORS_HINT' | translate
}}</small>
</span>
</a>
<a
class="support-btn support-btn--kofi"
href="https://ko-fi.com/4gray"
target="_blank"
rel="noreferrer noopener"
data-test-id="support-kofi"
>
<span class="support-btn__glyph">
<mat-icon>local_cafe</mat-icon>
</span>
<span class="support-btn__text">
<strong>Ko-fi</strong>
<small>{{
'SETTINGS.SUPPORT_KOFI_HINT' | translate
}}</small>
</span>
</a>
</div>
</div>
</div>
</section>
@@ -0,0 +1,17 @@
import { Component, input, ViewEncapsulation } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-settings-about-section',
imports: [MatIconModule, TranslateModule],
templateUrl: './settings-about-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsAboutSectionComponent {
readonly activeSection = input.required<string>();
readonly isDesktop = input(false);
readonly version = input<string | undefined>();
readonly updateMessage = input<string | undefined>();
}
@@ -0,0 +1,77 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'backup'"
id="backup"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>backup</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
</div>
</header>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
<p>
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control inline-actions">
<button
mat-button
class="inline-actions__button"
[disabled]="isRemovingAllPlaylists() || isExportingData()"
(click)="
$event.preventDefault();
$event.stopPropagation();
importData.emit()
"
>
{{ 'SETTINGS.IMPORT_DATA' | translate }}
</button>
<button
mat-button
class="inline-actions__button"
[class.inline-actions__button--busy]="isExportingData()"
[disabled]="isRemovingAllPlaylists() || isExportingData()"
[attr.aria-busy]="isExportingData()"
(click)="
$event.preventDefault();
$event.stopPropagation();
exportData.emit()
"
>
@if (isExportingData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
<span class="inline-actions__label">{{
'SETTINGS.EXPORTING_DATA' | translate
}}</span>
} @else {
<span class="inline-actions__label">{{
'SETTINGS.EXPORT_DATA' | translate
}}</span>
}
</button>
</div>
</div>
@if (isPwa()) {
<div class="settings-note">
{{ 'SETTINGS.EPG_NOTE' | translate }}
&nbsp;<a
href="https://github.com/4gray/iptvnator/releases"
target="_blank"
rel="noreferrer"
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
>
</div>
}
</section>
@@ -0,0 +1,27 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-settings-backup-section',
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
TranslateModule,
],
templateUrl: './settings-backup-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsBackupSectionComponent {
readonly activeSection = input.required<string>();
readonly isPwa = input(false);
readonly isRemovingAllPlaylists = input(false);
readonly isExportingData = input(false);
readonly importData = output<void>();
readonly exportData = output<void>();
}
@@ -0,0 +1,140 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'epg'"
id="epg"
[formGroup]="form()"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>list_alt</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
<p>{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}</p>
</div>
</header>
<div class="setting-item setting-item--full-control">
<div class="setting-item__control">
<ng-container formArrayName="epgUrl">
@if (epgUrl().length === 0) {
<div
class="epg-sources-empty"
data-test-id="epg-sources-empty"
>
<mat-icon class="epg-sources-empty__icon"
>event_busy</mat-icon
>
<h4 class="epg-sources-empty__title">
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
</h4>
<p class="epg-sources-empty__hint">
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
</p>
</div>
} @else {
<div class="epg-sources">
@for (_ of epgUrl().controls; track _; let i = $index) {
<div class="epg-source-row">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="w-full"
>
<mat-label>{{
'SETTINGS.EPG_URL_PLACEHOLDER'
| translate
}}</mat-label>
<input
matInput
type="url"
[formControlName]="i"
#epgField
/>
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
</mat-form-field>
@if (epgField.value) {
<app-epg-source-status
[url]="epgField.value"
/>
}
<button
mat-icon-button
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
type="button"
[disabled]="epgField.value === ''"
(click)="refreshEpg.emit(epgUrl().value[i])"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
type="button"
(click)="removeEpgSource.emit(i)"
>
<mat-icon>remove</mat-icon>
</button>
</div>
}
</div>
}
</ng-container>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="preferUploadedEpgOverXtream"
data-test-id="toggle-prefer-uploaded-epg"
></mat-checkbox>
</div>
</div>
<footer class="settings-group__footer">
<div class="inline-actions">
<button mat-button type="button" (click)="addEpgSource.emit()">
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-button
type="button"
[disabled]="epgUrl().length === 0 || isClearingEpgData()"
(click)="refreshAllEpg.emit()"
>
<mat-icon>refresh</mat-icon>
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
</button>
<button
mat-button
color="warn"
type="button"
[disabled]="isClearingEpgData()"
(click)="clearEpgData.emit()"
>
@if (isClearingEpgData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
} @else {
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
}
</button>
</div>
</footer>
</section>
@@ -0,0 +1,42 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-settings-epg-section',
imports: [
EpgSourceStatusComponent,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
],
templateUrl: './settings-epg-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsEpgSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly epgUrl = input.required<FormArray>();
readonly isClearingEpgData = input(false);
readonly refreshEpg = output<string>();
readonly removeEpgSource = output<number>();
readonly addEpgSource = output<void>();
readonly refreshAllEpg = output<void>();
readonly clearEpgData = output<void>();
}
@@ -0,0 +1,7 @@
import { FormControl, Validators } from '@angular/forms';
export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/;
export function createEpgUrlControl(value = ''): FormControl<string | null> {
return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]);
}
@@ -0,0 +1,174 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'general'"
id="general"
[formGroup]="form()"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>settings</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.GENERAL' | translate }}</h3>
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
</div>
</header>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="language"
data-test-id="select-language"
>
@for (
language of languageEnum() | keyvalue;
track language
) {
<mat-option
[value]="language.value"
[attr.data-test-id]="language.value"
>{{
'LANGUAGES.' + language.key | translate
}}</mat-option
>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.THEME' | translate"
data-test-id="select-theme"
>
@for (themeOption of themeOptions(); track themeOption.value) {
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.theme === themeOption.value
"
[attr.aria-checked]="
form().value.theme === themeOption.value
"
[attr.data-test-id]="themeOption.value"
role="radio"
(click)="selectTheme.emit(themeOption.value)"
>
<mat-icon>{{ themeOption.icon }}</mat-icon>
<span>{{ themeOption.labelKey | translate }}</span>
</button>
}
</div>
</div>
</div>
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
data-test-id="select-cover-size"
>
@for (option of coverSizeOptions(); track option.value) {
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.coverSize === option.value
"
[attr.aria-checked]="
form().value.coverSize === option.value
"
[attr.data-test-id]="'cover-size-' + option.value"
role="radio"
(click)="selectCoverSize.emit(option.value)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.labelKey | translate }}</span>
</button>
}
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
<p>
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="showDashboard"
data-test-id="toggle-show-dashboard"
></mat-checkbox>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
<p>
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.STARTUP_BEHAVIOR' | translate
}}</mat-label>
<mat-select
formControlName="startupBehavior"
data-test-id="select-startup-behavior"
>
@for (
option of startupBehaviorOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="option.value"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
<p>
{{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="showCaptions"></mat-checkbox>
</div>
</div>
</section>
@@ -0,0 +1,41 @@
import { CommonModule } from '@angular/common';
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { CoverSize, Language, Theme } from 'shared-interfaces';
import {
CoverSizeOption,
StartupBehaviorOption,
ThemeOption,
} from './settings.models';
@Component({
selector: 'app-settings-general-section',
imports: [
CommonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
ReactiveFormsModule,
TranslateModule,
],
templateUrl: './settings-general-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsGeneralSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly languageEnum = input.required<typeof Language>();
readonly themeOptions = input.required<ThemeOption[]>();
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
readonly startupBehaviorOptions = input.required<StartupBehaviorOption[]>();
readonly selectTheme = output<Theme>();
readonly selectCoverSize = output<CoverSize>();
}
@@ -0,0 +1,135 @@
import {
CoverSize,
StartupBehavior,
Theme,
VideoPlayer,
} from 'shared-interfaces';
import {
CoverSizeOption,
SettingsPlayerOption,
SettingsSection,
StartupBehaviorOption,
ThemeOption,
} from './settings.models';
export const SETTINGS_THEME_OPTIONS: ThemeOption[] = [
{
value: Theme.LightTheme,
icon: 'light_mode',
labelKey: 'THEMES.LIGHT_THEME',
},
{
value: Theme.DarkTheme,
icon: 'dark_mode',
labelKey: 'THEMES.DARK_THEME',
},
{
value: Theme.SystemTheme,
icon: 'desktop_windows',
labelKey: 'THEMES.SYSTEM_THEME',
},
];
export const SETTINGS_COVER_SIZE_OPTIONS: CoverSizeOption[] = [
{
value: 'small' satisfies CoverSize,
icon: 'view_module',
labelKey: 'SETTINGS.COVER_SIZE_SMALL',
},
{
value: 'medium' satisfies CoverSize,
icon: 'view_comfy',
labelKey: 'SETTINGS.COVER_SIZE_MEDIUM',
},
{
value: 'large' satisfies CoverSize,
icon: 'view_quilt',
labelKey: 'SETTINGS.COVER_SIZE_LARGE',
},
];
export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [
{
value: StartupBehavior.FirstView,
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
},
{
value: StartupBehavior.RestoreLastView,
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
},
];
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
{
id: VideoPlayer.MPV,
labelKey: 'SETTINGS.PLAYER_MPV',
},
{
id: VideoPlayer.VLC,
labelKey: 'SETTINGS.PLAYER_VLC',
},
];
export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [
{
id: VideoPlayer.Html5Player,
labelKey: 'SETTINGS.PLAYER_HTML5',
},
{
id: VideoPlayer.VideoJs,
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
},
{
id: VideoPlayer.ArtPlayer,
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
},
];
export function buildSettingsSectionNavItems(
isDesktop: boolean
): SettingsSection[] {
return [
{
id: 'general',
label: 'SETTINGS.NAV_GENERAL',
icon: 'tune',
visible: true,
},
{
id: 'playback',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
visible: true,
},
{
id: 'epg',
label: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
visible: isDesktop,
},
{
id: 'remote-control',
label: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
visible: isDesktop,
},
{
id: 'backup',
label: 'SETTINGS.NAV_BACKUP',
icon: 'backup',
visible: true,
},
{
id: 'reset',
label: 'SETTINGS.NAV_RESET',
icon: 'delete_sweep',
visible: true,
},
{
id: 'about',
label: 'SETTINGS.NAV_ABOUT',
icon: 'info',
visible: true,
},
];
}
@@ -0,0 +1,189 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'playback'"
id="playback"
[formGroup]="form()"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>play_circle</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.PLAYBACK' | translate }}</h3>
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
</div>
</header>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}</h4>
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="player"
data-test-id="select-video-player"
>
@for (player of players(); track player.id) {
<mat-option
[value]="player.id"
[attr.data-test-id]="player.id"
>{{ player.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STREAM_FORMAT' | translate }}</h4>
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="streamFormat"
data-test-id="select-stream-format"
>
@for (
streamFormat of streamFormatEnum() | keyvalue;
track streamFormat
) {
<mat-option
[value]="streamFormat.value"
[attr.data-test-id]="streamFormat.value"
>{{ streamFormat.value }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (isDesktop() && isExternalPlayerSelected()) {
<div
class="setting-item"
data-test-id="external-player-double-click-setting"
>
<div class="setting-item__meta">
<h4>
{{
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK'
| translate
}}
</h4>
<p>
{{
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="openStreamOnDoubleClick"
></mat-checkbox>
</div>
</div>
}
@if (isDesktop()) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}</h4>
<p>
{{
'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="showExternalPlaybackBar"
></mat-checkbox>
</div>
</div>
}
@if (form().value.player === 'mpv') {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
<p>
{{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label for="mpvPlayerPath">{{
'SETTINGS.MPV_PLAYER_PATH' | translate
}}</mat-label>
<input
matInput
type="text"
id="mpvPlayerPath"
formControlName="mpvPlayerPath"
/>
</mat-form-field>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}</h4>
<p>
{{ 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="mpvReuseInstance"></mat-checkbox>
</div>
</div>
}
@if (form().value.player === 'vlc') {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
<p>
{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label for="vlcPlayerPath">{{
'SETTINGS.VLC_PLAYER_PATH' | translate
}}</mat-label>
<input
matInput
type="text"
id="vlcPlayerPath"
formControlName="vlcPlayerPath"
/>
</mat-form-field>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}</h4>
<p>
{{ 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="vlcReuseInstance"></mat-checkbox>
</div>
</div>
}
</section>
@@ -0,0 +1,115 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { StreamFormat, VideoPlayer } from 'shared-interfaces';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
describe('SettingsPlaybackSectionComponent', () => {
let fixture: ComponentFixture<SettingsPlaybackSectionComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SettingsPlaybackSectionComponent,
NoopAnimationsModule,
ReactiveFormsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('form', createForm());
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', [
{
id: VideoPlayer.VideoJs,
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
},
{
id: VideoPlayer.MPV,
labelKey: 'SETTINGS.PLAYER_MPV',
},
]);
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
});
it('hides the external-player double-click option outside desktop builds', () => {
fixture.componentRef.setInput('form', createForm(VideoPlayer.MPV));
fixture.componentRef.setInput('isDesktop', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="external-player-double-click-setting"]'
)
).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain(
'SETTINGS.OPEN_STREAM_ON_DOUBLE_CLICK'
);
});
it('hides the external-player double-click option for embedded players', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="external-player-double-click-setting"]'
)
).toBeNull();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
'labels the double-click option as external-player behavior on desktop for %s',
(player) => {
fixture.componentRef.setInput('form', createForm(player));
fixture.componentRef.setInput('isDesktop', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="external-player-double-click-setting"]'
)
).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK'
);
}
);
it('updates the double-click option visibility when the selected player changes', () => {
const form = createForm();
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('isDesktop', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="external-player-double-click-setting"]'
)
).toBeNull();
form.controls['player'].setValue(VideoPlayer.MPV);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="external-player-double-click-setting"]'
)
).not.toBeNull();
});
});
function createForm(player = VideoPlayer.VideoJs): FormGroup {
return new FormGroup({
player: new FormControl(player),
streamFormat: new FormControl(StreamFormat.M3u8StreamFormat),
openStreamOnDoubleClick: new FormControl(false),
showExternalPlaybackBar: new FormControl(true),
mpvPlayerPath: new FormControl(''),
mpvReuseInstance: new FormControl(false),
vlcPlayerPath: new FormControl(''),
vlcReuseInstance: new FormControl(false),
});
}
@@ -0,0 +1,40 @@
import { CommonModule } from '@angular/common';
import { Component, input, ViewEncapsulation } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { StreamFormat, VideoPlayer } from 'shared-interfaces';
import { SettingsPlayerOption } from './settings.models';
@Component({
selector: 'app-settings-playback-section',
imports: [
CommonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
TranslateModule,
],
templateUrl: './settings-playback-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsPlaybackSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly players = input.required<SettingsPlayerOption[]>();
readonly streamFormatEnum = input.required<typeof StreamFormat>();
readonly isDesktop = input(false);
isExternalPlayerSelected(): boolean {
const player = this.form().value.player;
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
}
}
@@ -0,0 +1,139 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'remote-control'"
id="remote-control"
[formGroup]="form()"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>settings_remote</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h3>
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
</div>
</header>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h4>
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="remoteControl"
data-test-id="remote-control-enabled"
></mat-checkbox>
</div>
</div>
@if (form().value.remoteControl === true) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
<p>
{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.PORT_RANGE_LABEL' | translate
}}</mat-label>
<input
matInput
type="number"
id="remoteControlPort"
data-test-id="remote-control-port"
formControlName="remoteControlPort"
min="1"
max="65535"
step="1"
/>
@if (
form().get('remoteControlPort')?.hasError('required')
) {
<mat-error>{{
'SETTINGS.PORT_REQUIRED' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('min')) {
<mat-error>{{
'SETTINGS.PORT_MIN' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('max')) {
<mat-error>{{
'SETTINGS.PORT_MAX' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('pattern')) {
<mat-error>{{
'SETTINGS.PORT_PATTERN' | translate
}}</mat-error>
}
</mat-form-field>
</div>
</div>
@if (localIpAddresses().length > 0) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
<p>
{{
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control remote-control-urls">
@for (ip of localIpAddresses(); track ip) {
<div class="remote-control-url-item">
<div class="url-row">
<a
[href]="
'http://' +
ip +
':' +
form().value.remoteControlPort
"
target="_blank"
class="remote-control-url"
>
http://{{ ip }}:{{
form().value.remoteControlPort
}}
</a>
<button
mat-icon-button
type="button"
(click)="toggleQrCode.emit(ip)"
[matTooltip]="
'SETTINGS.SHOW_QR_CODE' | translate
"
>
<mat-icon>qr_code_2</mat-icon>
</button>
</div>
@if (visibleQrCodeIp() === ip) {
<div class="qr-code-container">
<qrcode
[qrdata]="
'http://' +
ip +
':' +
form().value.remoteControlPort
"
[width]="150"
errorCorrectionLevel="M"
></qrcode>
</div>
}
</div>
}
</div>
</div>
}
}
</section>
@@ -0,0 +1,36 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
@Component({
selector: 'app-settings-remote-control-section',
imports: [
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatTooltipModule,
QRCodeComponent,
ReactiveFormsModule,
TranslateModule,
],
templateUrl: './settings-remote-control-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsRemoteControlSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly localIpAddresses = input.required<string[]>();
readonly visibleQrCodeIp = input<string | null>(null);
readonly toggleQrCode = output<string>();
}
@@ -0,0 +1,88 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'reset'"
id="reset"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>delete_sweep</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
<p>{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}</p>
</div>
</header>
<div class="setting-item danger-zone">
<div class="danger-zone__header">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
</div>
<button
mat-button
class="danger-zone__button"
[class.danger-zone__button--busy]="isRemovingAllPlaylists()"
[disabled]="!canRemoveAllPlaylists()"
[attr.aria-busy]="isRemovingAllPlaylists()"
(click)="
$event.preventDefault();
$event.stopPropagation();
removeAll.emit()
"
color="warn"
>
@if (isRemovingAllPlaylists()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
<span class="inline-actions__label">{{
'SETTINGS.REMOVE_ALL_IN_PROGRESS' | translate
}}</span>
} @else {
<span class="inline-actions__label">{{
'SETTINGS.REMOVE_ALL_BUTTON' | translate
}}</span>
}
</button>
</div>
<div class="danger-zone__summary" aria-live="polite">
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon"
>folder_open</mat-icon
>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.M3U' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().m3u
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">cloud</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.XTREAM' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().xtream
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">cast</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.STALKER' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().stalker
}}</span>
</span>
</div>
@if (removeAllProgressLabel(); as progressLabel) {
<p class="danger-zone__progress">
{{ progressLabel }}
</p>
}
</div>
</section>
@@ -0,0 +1,29 @@
import { Component, input, output, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsPlaylistDeleteSummary } from './settings.models';
@Component({
selector: 'app-settings-reset-section',
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
TranslateModule,
],
templateUrl: './settings-reset-section.component.html',
encapsulation: ViewEncapsulation.None,
styles: [':host { display: contents; }'],
})
export class SettingsResetSectionComponent {
readonly activeSection = input.required<string>();
readonly isRemovingAllPlaylists = input(false);
readonly canRemoveAllPlaylists = input(false);
readonly playlistDeleteSummary =
input.required<SettingsPlaylistDeleteSummary>();
readonly removeAllProgressLabel = input<string | null>(null);
readonly removeAll = output<void>();
}
@@ -0,0 +1,270 @@
import {
AfterViewInit,
Directive,
effect,
ElementRef,
inject,
Injector,
OnDestroy,
} from '@angular/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { ObservedSettingsSection } from './settings.models';
@Directive({
selector: '[appSettingsSectionScroll]',
})
export class SettingsSectionScrollDirective
implements AfterViewInit, OnDestroy
{
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);
private readonly settingsCtx = inject(SettingsContextService);
private sectionObserver?: IntersectionObserver;
private pendingScrollClearTimer: ReturnType<
typeof window.setTimeout
> | null = null;
private pendingScrollClearRoot: HTMLElement | null = null;
private pendingScrollEndListener: (() => void) | null = null;
constructor() {
effect(
() => {
const sectionId = this.settingsCtx.pendingScrollTarget();
if (!sectionId || typeof document === 'undefined') {
return;
}
const scrollRoot = this.scrollToSection(sectionId);
this.schedulePendingScrollTargetClear(scrollRoot);
},
{ injector: this.injector }
);
effect(
(onCleanup) => {
const activeSectionId = this.settingsCtx.activeSection();
const activeSectionElement =
this.elementRef.nativeElement.querySelector(
`#${activeSectionId}`
) as HTMLElement | null;
if (!activeSectionElement) {
return;
}
const animation = activeSectionElement.animate(
[
{
boxShadow:
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
},
{
boxShadow:
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
},
{
boxShadow:
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
},
],
{
duration: 260,
easing: 'ease-out',
}
);
onCleanup(() => animation.cancel());
},
{ injector: this.injector }
);
}
ngAfterViewInit(): void {
requestAnimationFrame(() => this.setupSectionObserver());
}
ngOnDestroy(): void {
this.cancelPendingScrollTargetClear();
this.sectionObserver?.disconnect();
}
private setupSectionObserver(): void {
if (typeof IntersectionObserver === 'undefined') {
return;
}
const scrollRoot = this.getScrollRoot();
const contentSections = Array.from(
this.elementRef.nativeElement.querySelectorAll(
'.settings-group[id]'
)
) as HTMLElement[];
const sections: ObservedSettingsSection[] = contentSections.map(
(section) => ({
id: section.id,
element: section,
})
);
if (sections.length === 0) {
return;
}
this.sectionObserver?.disconnect();
this.sectionObserver = new IntersectionObserver(
() => {
if (this.settingsCtx.pendingScrollTarget()) {
return;
}
const activeSection = this.resolveActiveSection(sections);
if (activeSection) {
this.settingsCtx.setActiveSection(activeSection);
}
},
{
root: scrollRoot,
threshold: [0.12, 0.24, 0.4, 0.6],
rootMargin: '-18% 0px -52% 0px',
}
);
sections.forEach((section) =>
this.sectionObserver?.observe(section.element)
);
const initialSection = this.resolveActiveSection(sections);
if (initialSection) {
this.settingsCtx.setActiveSection(initialSection);
}
}
private resolveActiveSection(
sections: ObservedSettingsSection[]
): string | null {
const scrollRoot = this.getScrollRoot();
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
const sectionAtActivationLine = sections.find((section) => {
const rect = section.element.getBoundingClientRect();
return rect.top <= activationLine && rect.bottom >= activationLine;
});
if (sectionAtActivationLine) {
return sectionAtActivationLine.id;
}
const nearestSection = sections
.map((section) => ({
id: section.id,
distance: Math.abs(
section.element.getBoundingClientRect().top - activationLine
),
}))
.sort((a, b) => a.distance - b.distance)[0];
return nearestSection?.id ?? null;
}
private getScrollRoot(): HTMLElement | null {
return this.elementRef.nativeElement.closest(
'main.workspace-content'
) as HTMLElement | null;
}
private schedulePendingScrollTargetClear(
scrollRoot: HTMLElement | null
): void {
const clearPendingScrollTarget = () => {
this.cancelPendingScrollTargetClear();
this.settingsCtx.clearPendingScrollTarget();
};
this.cancelPendingScrollTargetClear();
this.pendingScrollClearTimer = window.setTimeout(
clearPendingScrollTarget,
SettingsSectionScrollDirective.PENDING_SCROLL_CLEAR_DELAY_MS
);
this.pendingScrollClearRoot = scrollRoot;
this.pendingScrollEndListener = clearPendingScrollTarget;
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
once: true,
});
}
private cancelPendingScrollTargetClear(): void {
if (this.pendingScrollClearTimer) {
clearTimeout(this.pendingScrollClearTimer);
this.pendingScrollClearTimer = null;
}
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
this.pendingScrollClearRoot.removeEventListener?.(
'scrollend',
this.pendingScrollEndListener
);
}
this.pendingScrollClearRoot = null;
this.pendingScrollEndListener = null;
}
private scrollToSection(sectionId: string): HTMLElement | null {
const sectionElement = document.getElementById(sectionId);
if (!sectionElement) {
return null;
}
const scrollRoot = this.getScrollRoot();
if (!scrollRoot) {
sectionElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
return null;
}
const rootRect = scrollRoot.getBoundingClientRect();
const sectionRect = sectionElement.getBoundingClientRect();
const sectionTop =
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
const sectionBottom = sectionTop + sectionRect.height;
const visibleTop =
scrollRoot.scrollTop +
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
const visibleBottom =
scrollRoot.scrollTop +
scrollRoot.clientHeight -
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
let nextScrollTop = scrollRoot.scrollTop;
if (sectionTop < visibleTop) {
nextScrollTop =
sectionTop -
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
} else if (sectionBottom > visibleBottom) {
nextScrollTop =
sectionBottom -
scrollRoot.clientHeight +
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
}
const maxScrollTop = Math.max(
0,
scrollRoot.scrollHeight - scrollRoot.clientHeight
);
scrollRoot.scrollTo({
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
behavior: 'smooth',
});
return scrollRoot;
}
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
:host {
.settings-page-host {
display: block;
}
@@ -745,11 +745,7 @@
var(--mat-sys-on-surface) 12%,
transparent
);
color: color-mix(
in srgb,
var(--mat-sys-on-surface) 38%,
transparent
);
color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent);
box-shadow: none;
}
}
@@ -15,6 +15,7 @@ import { MatListModule } from '@angular/material/list';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { EpgService } from '@iptvnator/epg/data-access';
@@ -30,7 +31,7 @@ import {
PlaylistsService,
} from 'services';
import {
/* EmbeddedMpvSupport, */
EmbeddedMpvSupport,
Language,
PlaylistMeta,
StartupBehavior,
@@ -52,6 +53,7 @@ import { from, of } from 'rxjs';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
class MatSnackBarStub {
open = jest.fn();
@@ -66,6 +68,7 @@ export class MockRouter {
const DEFAULT_SETTINGS = {
player: VideoPlayer.VideoJs,
streamFormat: StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
@@ -75,9 +78,12 @@ const DEFAULT_SETTINGS = {
mpvPlayerPath: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
coverSize: 'medium',
preferUploadedEpgOverXtream: false,
};
class MockSettingsStore {
@@ -110,6 +116,15 @@ class MockSettingsService {
);
}
interface SettingsSectionScrollDirectiveTestApi {
getScrollRoot(): HTMLElement | null;
}
interface SettingsComponentPrivateTestApi {
matDialog: MatDialog;
waitForUiFeedbackFrame(): Promise<void>;
}
describe('SettingsComponent', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
@@ -117,7 +132,6 @@ describe('SettingsComponent', () => {
let router: Router;
let settingsStore: unknown;
let translate: TranslateService;
let epgService: EpgService;
let dialogService: DialogService;
let playlistsService: PlaylistsService;
let playlistBackupService: PlaylistBackupService;
@@ -139,6 +153,11 @@ describe('SettingsComponent', () => {
...overrides,
});
const createDialogRef = (result: boolean): ReturnType<MatDialog['open']> =>
({
afterClosed: () => of(result),
}) as unknown as ReturnType<MatDialog['open']>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [
@@ -244,7 +263,6 @@ describe('SettingsComponent', () => {
settingsStore = TestBed.inject(SettingsStore);
router = TestBed.inject(Router);
translate = TestBed.inject(TranslateService);
epgService = TestBed.inject(EpgService);
dialogService = TestBed.inject(DialogService);
playlistsService = TestBed.inject(PlaylistsService);
playlistBackupService = TestBed.inject(PlaylistBackupService);
@@ -271,6 +289,12 @@ describe('SettingsComponent', () => {
fixture.detectChanges();
}
function privateApi(
settingsComponent: SettingsComponent
): SettingsComponentPrivateTestApi {
return settingsComponent as unknown as SettingsComponentPrivateTestApi;
}
it('should create and init component', () => {
expect(component).toBeTruthy();
});
@@ -332,10 +356,14 @@ describe('SettingsComponent', () => {
scrollComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
jest.spyOn(scrollComponent as any, 'getScrollRoot').mockReturnValue(
scrollRoot
);
scrollFixture.detectChanges();
const scrollDirective = scrollFixture.debugElement
.query(By.directive(SettingsSectionScrollDirective))
.injector.get(SettingsSectionScrollDirective);
jest.spyOn(
scrollDirective as unknown as SettingsSectionScrollDirectiveTestApi,
'getScrollRoot'
).mockReturnValue(scrollRoot);
const getElementByIdSpy = jest
.spyOn(document, 'getElementById')
@@ -402,7 +430,7 @@ describe('SettingsComponent', () => {
});
});
/* it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => {
it('hides the embedded mpv option when the desktop support probe reports unsupported', async () => {
window.electron = {
...window.electron,
getEmbeddedMpvSupport: jest.fn().mockResolvedValue({
@@ -416,9 +444,9 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(false);
});
@@ -435,9 +463,9 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(true);
});
@@ -458,9 +486,9 @@ describe('SettingsComponent', () => {
expect(component.settingsForm.value).toEqual(DEFAULT_SETTINGS);
expect(window.electron.getEmbeddedMpvSupport).toHaveBeenCalled();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(false);
resolveSupport!({
@@ -470,11 +498,11 @@ describe('SettingsComponent', () => {
await fixture.whenStable();
expect(
component.players().some(
(player) => player.id === VideoPlayer.EmbeddedMpv
)
component
.players()
.some((player) => player.id === VideoPlayer.EmbeddedMpv)
).toBe(true);
}); */
});
});
describe('Version check', () => {
@@ -548,10 +576,8 @@ describe('SettingsComponent', () => {
]);
const openSpy = jest
.spyOn((component as any).matDialog, 'open')
.mockReturnValue({
afterClosed: () => of(false),
} as any);
.spyOn(privateApi(component).matDialog, 'open')
.mockReturnValue(createDialogRef(false));
component.removeAll();
@@ -587,9 +613,9 @@ describe('SettingsComponent', () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
jest.spyOn((component as any).matDialog, 'open').mockReturnValue({
afterClosed: () => of(true),
} as any);
jest.spyOn(privateApi(component).matDialog, 'open').mockReturnValue(
createDialogRef(true)
);
jest.spyOn(translate, 'instant').mockImplementation(
(key: string, params?: Record<string, number>) => {
if (key === 'SETTINGS.REMOVE_ALL_PROGRESS') {
@@ -599,7 +625,7 @@ describe('SettingsComponent', () => {
}
);
jest.spyOn(
component as any,
privateApi(component),
'waitForUiFeedbackFrame'
).mockResolvedValue(undefined);
@@ -679,13 +705,12 @@ describe('SettingsComponent', () => {
mockStore.refreshState();
browserFixture.detectChanges();
jest.spyOn((browserComponent as any).matDialog, 'open').mockReturnValue(
{
afterClosed: () => of(true),
} as any
);
jest.spyOn(
browserComponent as any,
privateApi(browserComponent).matDialog,
'open'
).mockReturnValue(createDialogRef(true));
jest.spyOn(
privateApi(browserComponent),
'waitForUiFeedbackFrame'
).mockResolvedValue(undefined);
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
@@ -845,13 +870,35 @@ describe('SettingsComponent', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
});
it('should update the selected theme and mark the form dirty', () => {
component.selectTheme(Theme.DarkTheme);
it('updates the selected theme through the general section and marks the form dirty', () => {
const darkThemeButton = (
fixture.nativeElement as HTMLElement
).querySelector('[data-test-id="DARK_THEME"]') as HTMLButtonElement;
darkThemeButton.click();
fixture.detectChanges();
expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme);
expect(component.settingsForm.dirty).toBeTruthy();
});
it('updates cover size through the general section output', () => {
const mockStore = settingsStore as unknown as MockSettingsStore;
const largeCoverButton = (
fixture.nativeElement as HTMLElement
).querySelector(
'[data-test-id="cover-size-large"]'
) as HTMLButtonElement;
largeCoverButton.click();
fixture.detectChanges();
expect(component.settingsForm.value.coverSize).toBe('large');
expect(mockStore.updateSettings).toHaveBeenCalledWith({
coverSize: 'large',
});
});
it('renders workspace startup controls with the expected defaults', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
@@ -881,4 +928,28 @@ describe('SettingsComponent', () => {
component.settingsForm.value
);
});
it('clears external player paths in Electron when saved as empty', async () => {
const mockStore = settingsStore as unknown as MockSettingsStore;
mockStore.updateSettings.mockResolvedValue(undefined);
const setMpvPlayerPath = jest.spyOn(
window.electron,
'setMpvPlayerPath'
);
const setVlcPlayerPath = jest.spyOn(
window.electron,
'setVlcPlayerPath'
);
component.settingsForm.patchValue({
mpvPlayerPath: '',
vlcPlayerPath: '',
});
component.onSubmit();
await fixture.whenStable();
expect(setMpvPlayerPath).toHaveBeenCalledWith('');
expect(setVlcPlayerPath).toHaveBeenCalledWith('');
});
});
+89 -421
View File
@@ -2,44 +2,32 @@ import { CommonModule } from '@angular/common';
import {
Component,
computed,
effect,
ElementRef,
inject,
Injector,
Input,
OnDestroy,
OnInit,
signal,
ViewEncapsulation,
} from '@angular/core';
import {
FormArray,
FormBuilder,
FormControl,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
} from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { EpgService } from '@iptvnator/epg/data-access';
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { DialogService } from 'components';
import {
PlaylistActions,
@@ -56,7 +44,8 @@ import {
PlaylistsService,
} from 'services';
import {
/* EmbeddedMpvSupport, */
EmbeddedMpvSupport,
CoverSize,
Language,
StartupBehavior,
StreamFormat,
@@ -65,63 +54,57 @@ import {
} from 'shared-interfaces';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from './../services/settings.service';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
import {
SettingsDeleteAllPlaylistsDialogComponent,
SettingsDeleteAllPlaylistsDialogData,
} from './settings-delete-all-playlists-dialog.component';
interface SettingsSection {
id: string;
label: string;
icon: string;
visible: boolean;
}
interface ObservedSettingsSection {
id: string;
element: HTMLElement;
}
interface ThemeOption {
value: Theme;
icon: string;
labelKey: string;
}
interface StartupBehaviorOption {
value: StartupBehavior;
labelKey: string;
}
type SettingsPlaylistDeleteSummary =
SettingsDeleteAllPlaylistsDialogData['summary'];
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
import { createEpgUrlControl } from './settings-form.utils';
import { SettingsGeneralSectionComponent } from './settings-general-section.component';
import {
SettingsPlaylistDeleteSummary,
SettingsSection,
} from './settings.models';
import {
buildSettingsSectionNavItems,
SETTINGS_COVER_SIZE_OPTIONS,
SETTINGS_EMBEDDED_PLAYER_OPTIONS,
SETTINGS_OS_PLAYER_OPTIONS,
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
SETTINGS_THEME_OPTIONS,
} from './settings-options';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
import { SettingsResetSectionComponent } from './settings-reset-section.component';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
@Component({
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
host: {
class: 'settings-page-host',
},
encapsulation: ViewEncapsulation.None,
imports: [
CommonModule,
EpgSourceStatusComponent,
FormsModule,
MatButtonModule,
MatCheckboxModule,
MatDividerModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
MatDialogModule,
QRCodeComponent,
SettingsAboutSectionComponent,
SettingsBackupSectionComponent,
SettingsEpgSectionComponent,
SettingsGeneralSectionComponent,
SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
SettingsSectionScrollDirective,
],
})
export class SettingsComponent implements OnInit, OnDestroy {
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
private dialogService = inject(DialogService);
public dataService = inject(DataService);
private epgService = inject(EpgService);
@@ -134,8 +117,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
private translate = inject(TranslateService);
private matDialog = inject(MatDialog);
private playlistBackupService = inject(PlaylistBackupService);
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);
private readonly databaseService = inject(DatabaseService);
private readonly dialogData = inject<{ isDialog: boolean } | null>(
MAT_DIALOG_DATA,
@@ -151,10 +132,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Flag that indicates whether the app runs in electron environment */
readonly isDesktop = !!window.electron;
/* readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
readonly supportsEmbeddedMpv = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
); */
);
isPwa = this.dataService.getAppEnvironment() === 'pwa';
@@ -162,38 +143,20 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly activeSection = this.settingsCtx.activeSection;
readonly osPlayers = computed(() => [
/* ...(this.supportsEmbeddedMpv()
...(this.supportsEmbeddedMpv()
? [
{
id: VideoPlayer.EmbeddedMpv,
labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV',
},
]
: []), */
{
id: VideoPlayer.MPV,
labelKey: 'SETTINGS.PLAYER_MPV',
},
{
id: VideoPlayer.VLC,
labelKey: 'SETTINGS.PLAYER_VLC',
},
: []),
...SETTINGS_OS_PLAYER_OPTIONS,
]);
/** Player options */
readonly players = computed(() => [
{
id: VideoPlayer.Html5Player,
labelKey: 'SETTINGS.PLAYER_HTML5',
},
{
id: VideoPlayer.VideoJs,
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
},
{
id: VideoPlayer.ArtPlayer,
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
},
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
...(this.isDesktop ? this.osPlayers() : []),
]);
@@ -207,40 +170,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
epgAvailable$ = this.store.select(selectIsEpgAvailable);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly themeOptions: ThemeOption[] = [
{
value: Theme.LightTheme,
icon: 'light_mode',
labelKey: 'THEMES.LIGHT_THEME',
},
{
value: Theme.DarkTheme,
icon: 'dark_mode',
labelKey: 'THEMES.DARK_THEME',
},
{
value: Theme.SystemTheme,
icon: 'desktop_windows',
labelKey: 'THEMES.SYSTEM_THEME',
},
];
readonly startupBehaviorOptions: StartupBehaviorOption[] = [
{
value: StartupBehavior.FirstView,
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
},
{
value: StartupBehavior.RestoreLastView,
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
},
];
readonly themeOptions = SETTINGS_THEME_OPTIONS;
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
/** Settings form object */
settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
...(this.isDesktop ? { epgUrl: new FormArray([]) } : {}),
streamFormat: StreamFormat.M3u8StreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
@@ -250,6 +189,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
mpvPlayerPath: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
vlcReuseInstance: false,
remoteControl: false,
remoteControlPort: [
8765,
@@ -260,6 +200,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
Validators.pattern(/^\d+$/),
],
],
coverSize: 'medium' as CoverSize,
...(this.isDesktop
? { preferUploadedEpgOverXtream: false }
: {}),
});
/** Form array with epg sources */
@@ -276,57 +220,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly removeAllProgress = signal<DbOperationEvent | null>(null);
private settingsStore = inject(SettingsStore);
private sectionObserver?: IntersectionObserver;
private pendingScrollClearTimer: ReturnType<
typeof window.setTimeout
> | null = null;
private pendingScrollClearRoot: HTMLElement | null = null;
private pendingScrollEndListener: (() => void) | null = null;
readonly sectionNavItems: SettingsSection[] = [
{
id: 'general',
label: 'SETTINGS.NAV_GENERAL',
icon: 'tune',
visible: true,
},
{
id: 'playback',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
visible: true,
},
{
id: 'epg',
label: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
visible: this.isDesktop,
},
{
id: 'remote-control',
label: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
visible: this.isDesktop,
},
{
id: 'backup',
label: 'SETTINGS.NAV_BACKUP',
icon: 'backup',
visible: true,
},
{
id: 'reset',
label: 'SETTINGS.NAV_RESET',
icon: 'delete_sweep',
visible: true,
},
{
id: 'about',
label: 'SETTINGS.NAV_ABOUT',
icon: 'info',
visible: true,
},
];
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems(
this.isDesktop
);
readonly playlistDeleteSummary = computed<SettingsPlaylistDeleteSummary>(
() => {
@@ -372,59 +268,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
return this.translate.instant('SETTINGS.REMOVE_ALL_IN_PROGRESS');
});
constructor() {
effect(
() => {
const sectionId = this.settingsCtx.pendingScrollTarget();
if (!sectionId || typeof document === 'undefined') {
return;
}
const scrollRoot = this.scrollToSection(sectionId);
this.schedulePendingScrollTargetClear(scrollRoot);
},
{ injector: this.injector }
);
effect(
(onCleanup) => {
const activeSectionId = this.activeSection();
const activeSectionElement =
this.elementRef.nativeElement.querySelector(
`#${activeSectionId}`
) as HTMLElement | null;
if (!activeSectionElement) {
return;
}
const animation = activeSectionElement.animate(
[
{
boxShadow:
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
},
{
boxShadow:
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
},
{
boxShadow:
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
},
],
{
duration: 260,
easing: 'ease-out',
}
);
onCleanup(() => animation.cancel());
},
{ injector: this.injector }
);
}
get sectionNav(): SettingsSection[] {
return this.sectionNavItems.filter((section) => section.visible);
}
@@ -437,18 +280,16 @@ export class SettingsComponent implements OnInit, OnDestroy {
// Wait for settings to load before setting the form
await this.settingsStore.loadSettings();
this.setSettings();
/* void this.loadEmbeddedMpvSupport(); */
void this.loadEmbeddedMpvSupport();
this.checkAppVersion();
void this.fetchLocalIpAddresses();
if (!this.isDialog) {
this.settingsCtx.setSections(this.sectionNav);
}
requestAnimationFrame(() => this.setupSectionObserver());
}
/* private async loadEmbeddedMpvSupport(): Promise<void> {
private async loadEmbeddedMpvSupport(): Promise<void> {
if (!this.isDesktop) {
this.embeddedMpvSupport.set({
supported: false,
@@ -475,17 +316,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.embeddedMpvSupport.set({
supported: false,
platform: window.electron.platform,
reason:
error instanceof Error
? error.message
: String(error),
reason: error instanceof Error ? error.message : String(error),
});
}
} */
}
ngOnDestroy(): void {
this.cancelPendingScrollTargetClear();
this.sectionObserver?.disconnect();
this.settingsCtx.reset();
}
@@ -534,22 +370,29 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.settingsService.changeTheme(theme);
}
selectCoverSize(size: CoverSize): void {
if (this.settingsForm.value.coverSize === size) {
return;
}
this.settingsForm.patchValue({ coverSize: size });
this.settingsForm.get('coverSize')?.markAsDirty();
this.settingsForm.markAsDirty();
this.settingsStore.updateSettings({ coverSize: size });
}
/**
* Sets the epg urls to the form array
* @param epgUrls urls of the EPG sources
*/
setEpgUrls(epgUrls: string[] | string): void {
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls];
const filteredUrls = urls
.map((url) => url.trim())
.filter((url) => url !== '');
filteredUrls.forEach((url) => {
this.epgUrl.push(
new FormControl(url, [Validators.pattern(URL_REGEX)])
);
this.epgUrl.push(createEpgUrlControl(url));
});
}
@@ -605,23 +448,22 @@ export class SettingsComponent implements OnInit, OnDestroy {
* the indexed db store
*/
onSubmit(): void {
this.settingsStore.updateSettings(this.settingsForm.value).then(() => {
const settings = this.settingsForm.value;
const mpvPlayerPath = this.normalizeExternalPlayerPath(
settings.mpvPlayerPath
);
const vlcPlayerPath = this.normalizeExternalPlayerPath(
settings.vlcPlayerPath
);
this.settingsStore.updateSettings(settings).then(() => {
this.applyChangedSettings();
if (window.electron) {
window.electron.updateSettings(this.settingsForm.value);
window.electron.updateSettings(settings);
// Set player paths if using external players
if (this.settingsForm.value.mpvPlayerPath) {
window.electron.setMpvPlayerPath(
this.settingsForm.value.mpvPlayerPath
);
}
if (this.settingsForm.value.vlcPlayerPath) {
window.electron.setVlcPlayerPath(
this.settingsForm.value.vlcPlayerPath
);
}
window.electron.setMpvPlayerPath(mpvPlayerPath);
window.electron.setVlcPlayerPath(vlcPlayerPath);
}
});
if (this.isDialog) {
@@ -629,6 +471,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
}
private normalizeExternalPlayerPath(
playerPath: string | null | undefined
): string {
return playerPath?.trim() ?? '';
}
/**
* Applies the changed settings to the app
*/
@@ -695,14 +543,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
* Initializes new entry in form array for EPG URL
*/
addEpgSource(): void {
this.epgUrl.insert(
this.epgUrl.length,
new FormControl('', {
validators: [
Validators.pattern(/^(http|https|file):\/\/[^ "]+$/),
],
})
);
this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl());
}
/**
@@ -953,179 +794,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.removeAllProgress.set(event);
}
private setupSectionObserver(): void {
if (typeof IntersectionObserver === 'undefined') {
return;
}
const scrollRoot = this.getScrollRoot();
const contentSections = Array.from(
this.elementRef.nativeElement.querySelectorAll(
'.settings-group[id]'
)
) as HTMLElement[];
const sections: ObservedSettingsSection[] = contentSections.map(
(section) => ({
id: section.id,
element: section,
})
);
if (sections.length === 0) {
return;
}
this.sectionObserver?.disconnect();
this.sectionObserver = new IntersectionObserver(
() => {
if (this.settingsCtx.pendingScrollTarget()) {
return;
}
const activeSection = this.resolveActiveSection(sections);
if (activeSection) {
this.settingsCtx.setActiveSection(activeSection);
}
},
{
root: scrollRoot,
threshold: [0.12, 0.24, 0.4, 0.6],
rootMargin: '-18% 0px -52% 0px',
}
);
sections.forEach((section) =>
this.sectionObserver?.observe(section.element)
);
const initialSection = this.resolveActiveSection(sections);
if (initialSection) {
this.settingsCtx.setActiveSection(initialSection);
}
}
private resolveActiveSection(
sections: ObservedSettingsSection[]
): string | null {
const scrollRoot = this.getScrollRoot();
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
const sectionAtActivationLine = sections.find((section) => {
const rect = section.element.getBoundingClientRect();
return rect.top <= activationLine && rect.bottom >= activationLine;
});
if (sectionAtActivationLine) {
return sectionAtActivationLine.id;
}
const nearestSection = sections
.map((section) => ({
id: section.id,
distance: Math.abs(
section.element.getBoundingClientRect().top - activationLine
),
}))
.sort((a, b) => a.distance - b.distance)[0];
return nearestSection?.id ?? null;
}
private getScrollRoot(): HTMLElement | null {
return this.elementRef.nativeElement.closest(
'main.workspace-content'
) as HTMLElement | null;
}
private schedulePendingScrollTargetClear(
scrollRoot: HTMLElement | null
): void {
const clearPendingScrollTarget = () => {
this.cancelPendingScrollTargetClear();
this.settingsCtx.clearPendingScrollTarget();
};
this.cancelPendingScrollTargetClear();
this.pendingScrollClearTimer = window.setTimeout(
clearPendingScrollTarget,
SettingsComponent.PENDING_SCROLL_CLEAR_DELAY_MS
);
this.pendingScrollClearRoot = scrollRoot;
this.pendingScrollEndListener = clearPendingScrollTarget;
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
once: true,
});
}
private cancelPendingScrollTargetClear(): void {
if (this.pendingScrollClearTimer) {
clearTimeout(this.pendingScrollClearTimer);
this.pendingScrollClearTimer = null;
}
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
this.pendingScrollClearRoot.removeEventListener?.(
'scrollend',
this.pendingScrollEndListener
);
}
this.pendingScrollClearRoot = null;
this.pendingScrollEndListener = null;
}
private scrollToSection(sectionId: string): HTMLElement | null {
const sectionElement = document.getElementById(sectionId);
if (!sectionElement) {
return null;
}
const scrollRoot = this.getScrollRoot();
if (!scrollRoot) {
sectionElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
return null;
}
const rootRect = scrollRoot.getBoundingClientRect();
const sectionRect = sectionElement.getBoundingClientRect();
const sectionTop =
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
const sectionBottom = sectionTop + sectionRect.height;
const visibleTop =
scrollRoot.scrollTop + SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
const visibleBottom =
scrollRoot.scrollTop +
scrollRoot.clientHeight -
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
let nextScrollTop = scrollRoot.scrollTop;
if (sectionTop < visibleTop) {
nextScrollTop =
sectionTop - SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
} else if (sectionBottom > visibleBottom) {
nextScrollTop =
sectionBottom -
scrollRoot.clientHeight +
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
}
const maxScrollTop = Math.max(
0,
scrollRoot.scrollHeight - scrollRoot.clientHeight
);
scrollRoot.scrollTo({
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
behavior: 'smooth',
});
return scrollRoot;
}
private openSettingsSnackbar(
message: string,
config: MatSnackBarConfig = {}
@@ -0,0 +1,47 @@
import {
CoverSize,
StartupBehavior,
Theme,
VideoPlayer,
} from 'shared-interfaces';
export interface SettingsSection {
id: string;
label: string;
icon: string;
visible: boolean;
}
export interface ObservedSettingsSection {
id: string;
element: HTMLElement;
}
export interface ThemeOption {
value: Theme;
icon: string;
labelKey: string;
}
export interface StartupBehaviorOption {
value: StartupBehavior;
labelKey: string;
}
export interface CoverSizeOption {
value: CoverSize;
icon: string;
labelKey: string;
}
export interface SettingsPlayerOption {
id: VideoPlayer;
labelKey: string;
}
export interface SettingsPlaylistDeleteSummary {
total: number;
m3u: number;
xtream: number;
stalker: number;
}
+609 -63
View File
@@ -1,5 +1,9 @@
{
"CANCEL": "إلغاء",
"CLEAR": "مسح",
"CLOSE": "إغلاق",
"SHOW_MORE": "المزيد",
"SHOW_LESS": "أقل",
"PLAYLISTS": "قوائم التشغيل",
"HOME": {
"TITLE": "IPTVnator",
@@ -21,6 +25,36 @@
"FEATURE_M3U": "ملفات M3U/M3U8",
"FEATURE_XTREAM": "Xtream Codes",
"FEATURE_STALKER": "بوابة Stalker",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"NO_RESULTS_TITLE": "لم يتم العثور على قوائم تشغيل",
"NO_RESULTS_DESCRIPTION": "لا توجد قوائم تشغيل تطابق بحثك. حاول تعديل مصطلحات البحث أو امسح الفلتر لرؤية جميع القوائم.",
"ADDED_VIA_URL": "تمت الإضافة عبر رابط URL. الرابط:",
@@ -53,62 +87,62 @@
"PASSWORD": "كلمة المرور",
"MAC_ADDRESS": "عنوان MAC",
"PLAYLIST_EXPORT_SUCCESS": "تم تصدير القائمة بنجاح.",
"PLAYLIST_EXPORT_ERROR": "خطأ: فشل تصدير القائمة."
"PLAYLIST_EXPORT_ERROR": "خطأ: فشل تصدير القائمة.",
"COPY_URL": "نسخ الرابط إلى الحافظة",
"URL_COPIED": "تم نسخ الرابط إلى الحافظة"
},
"REMOVE_DIALOG": {
"TITLE": "حذف القائمة",
"MESSAGE": "هل أنت متأكد من رغبتك في حذف هذه القائمة نهائيًا؟",
"SUCCESS": "تم حذف القائمة بنجاح"
"SUCCESS": "تم حذف القائمة بنجاح",
"IN_PROGRESS": "جارٍ حذف قائمة التشغيل...",
"DELETING_FAVORITES": "جارٍ حذف المفضلة...",
"DELETING_RECENT": "جارٍ حذف العناصر التي تمت مشاهدتها مؤخرًا...",
"DELETING_PROGRESS": "جارٍ حذف تقدم التشغيل...",
"DELETING_DOWNLOADS": "جارٍ حذف التنزيلات...",
"DELETING_CONTENT": "جارٍ حذف المحتوى المخزن مؤقتًا...",
"DELETING_CATEGORIES": "جارٍ حذف الفئات...",
"DELETING_PLAYLIST": "جارٍ حذف سجل قائمة التشغيل..."
},
"UPDATED": "تم التحديث",
"REFRESH": "تحديث القائمة",
"REFRESH_XTREAM": "تحديث قائمة Xtream من الخادم البعيد",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "تحديث قائمة Xtream",
"MESSAGE": "سيؤدي ذلك إلى حذف ذاكرة Xtream المؤقتة المحلية وإعادة استيراد الفئات والقنوات من الخادم البعيد. ستتم استعادة المفضلة، والعناصر التي تمت مشاهدتها مؤخرًا، وحالة عرض الفئات المخفية، وتقدم التشغيل بعد إعادة الاستيراد بنجاح إذا كان المحتوى المطابق لا يزال موجودًا. إذا تعذر الوصول إلى الخادم، تبقى عملية الاستعادة معلقة حتى يتم تحديث ناجح لاحق. هل تريد المتابعة؟",
"STARTED": "بدأ التحديث. جارٍ الانتقال إلى قائمة التشغيل...",
"ERROR": "فشل تحديث قائمة التشغيل. يرجى المحاولة مرة أخرى.",
"IN_PROGRESS": "جارٍ تحديث قائمة التشغيل...",
"COLLECTING_DATA": "جارٍ جمع المفضلة والعناصر التي تمت مشاهدتها مؤخرًا...",
"DELETING_CONTENT": "جارٍ حذف المحتوى المخزن مؤقتًا...",
"DELETING_CATEGORIES": "جارٍ حذف الفئات المخزنة مؤقتًا..."
},
"GLOBAL_FAVORITES": "المفضلة العامة",
"GLOBAL_FAVORITES_DESCRIPTION": "قائمة تلقائية تجمع القنوات المفضلة من جميع القوائم",
"MY_PLAYLISTS": "قوائم التشغيل الخاصة بي",
"MY_PLAYLISTS_SUBTITLE": "جميع القوائم المتاحة",
"PLAYLIST_UPDATE_SUCCESS": "نجاح! تم تحديث القائمة.",
"PLAYLIST_UPDATE_ERROR": "خطأ في تحديث تفاصيل القائمة.",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen."
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "فشل تحديث قائمة التشغيل. الملف المحلي لم يعد متاحًا. تحقق من مسار الملف أو أعد استيراد قائمة التشغيل.",
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "فشل تحديث قائمة التشغيل. لم يعد بإمكان التطبيق الوصول إلى الملف المحلي.",
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! تم تحديث قوائم التشغيل بنجاح",
"AUTO_REFRESH_ENABLED": "تم تفعيل التحديث التلقائي"
},
"FILE_UPLOAD": {
"DRAG_DROP": "اسحب وأفلت الملفات هنا",
"OR": "أو",
"CHOOSE_PLAYLIST": "اختر قائمة تشغيل",
"BROWSE": "تصفح الملفات",
"REJECTED": "تم رفض الملف، تنسيق غير مدعوم {{filename}}."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "رابط قائمة التشغيل (M3U/M3U8)",
"ADD_PLAYLIST": "إضافة القائمة",
"CORS_NOTE": "ملاحظة: لتجنب مشاكل CORS، يستخدم التطبيق خدمة بروكسي عامة. يُنصح باستيراد القوائم الحساسة عبر الملفات."
"CORS_NOTE": "ملاحظة: لتجنب مشاكل CORS، يستخدم التطبيق خدمة بروكسي عامة. يُنصح باستيراد القوائم الحساسة عبر الملفات.",
"ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
"ERROR_403": "تم رفض الوصول (403): رفض الخادم الطلب. قد يتطلب الرابط مصادقة أو أن قائمة التشغيل مقيدة.",
"ERROR_404": "قائمة التشغيل غير موجودة (404): الرابط لا يشير إلى قائمة تشغيل صالحة. يرجى التحقق من الرابط.",
"ERROR_401": "غير مصرح (401): المصادقة مطلوبة للوصول إلى قائمة التشغيل هذه."
},
"TEXT_IMPORT": {
"LABEL": "إدراج قائمة تشغيل M3U(8) كنص",
@@ -126,25 +160,68 @@
"STALKER_PORTAL": {
"TITLE": "العنوان",
"MAC_ADDRESS": "عنوان MAC",
"SERIAL_NUMBER": "الرقم التسلسلي (اختياري)",
"SERIAL_NUMBER_HINT": "استخدمه إذا كنت مسجلاً بالفعل لدى المزود",
"DEVICE_ID_1": "معرّف الجهاز 1 (اختياري)",
"DEVICE_ID_2": "معرّف الجهاز 2 (اختياري)",
"DEVICE_ID_HINT": "64 حرفًا سداسيًا عشريًا - استخدمه إذا كان المزود يتطلب معرّف جهاز محدد",
"SIGNATURE_1": "التوقيع 1 (اختياري)",
"SIGNATURE_2": "التوقيع 2 (اختياري)",
"SIGNATURE_HINT": "64 حرفًا سداسيًا عشريًا - استخدمه إذا كان المزود يتطلب توقيعات للتحقق من الجهاز",
"SERVER_URL": "رابط الخادم",
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com/stalker_portal)",
"ADD": "إضافة"
"ADD": "إضافة",
"VALIDATING": "جارٍ التحقق من البوابة..."
},
"FILTER_BY_NAME": "التصفية حسب الاسم",
"FILTER_BY_TYPE": "التصفية حسب النوع",
"FILTER_AND_SORT": "تصفية وفرز",
"SORT_PLAYLISTS": "فرز القوائم",
"SORT_BY": "ترتيب حسب",
"SORT_OPTIONS": {
"NAME_ASC": "الاسم (أ-ي)",
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)"
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (Drag & Drop)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "مستوردة كنص",
"PARSING_ERROR": "خطأ: فشل التحليل، ليست قائمة تشغيل صالحة:"
},
"SETTINGS": {
"TITLE": "الإعدادات",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "تغيير تكوين التطبيق",
"PLAYBACK": "التشغيل",
"EPG_SOURCES": "مصادر EPG",
"EPG_SOURCES_DESCRIPTION": "أضف رابطًا واحدًا أو أكثر كمصادر لـ EPG",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "تفضيل مصادر EPG الخاصة بك لقوائم Xtream",
"PREFER_UPLOADED_EPG_HINT": "بشكل افتراضي، تستخدم قوائم Xtream دليل البرامج من المزود الخاص بك، وتُستخدم مصادر EPG التي حمّلتها لملء القنوات المفقودة فقط. فعّل هذا الخيار لاستخدام EPG الذي حمّلته أولاً.",
"DATA": "البيانات",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"ABOUT": "حول",
"NAV_GENERAL": "عام",
"NAV_PLAYBACK": "التشغيل",
"NAV_EPG": "EPG",
"NAV_REMOTE": "عن بعد",
"NAV_DATA": "البيانات",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "حول",
"APP_PREFERENCES": "تفضيلات التطبيق",
"HERO_EYEBROW": "إعدادات مساحة العمل",
"HERO_TITLE": "خصّص تجربة IPTVnator",
"HERO_DESCRIPTION": "اضبط التشغيل، والأدلة، والوصول عن بعد، ومعالجة البيانات من مكان واحد دون فقدان نمط مساحة العمل الحالي.",
"SUBTITLE": "تعديل تكوين التطبيق",
"DESCRIPTION": "يرجى ضبط الخيارات أدناه ثم النقر على حفظ التغييرات",
"EPG_URL_LABEL": "رابط EPG (XML/XML.GZ)",
@@ -156,29 +233,72 @@
"EPG_STATUS_STALE": "يحتاج تحديث",
"EPG_STATUS_ERROR": "فشل",
"VIDEO_PLAYER_LABEL": "مشغل الفيديو",
"PLAYER_HTML5": "مشغل فيديو HTML5",
"PLAYER_VIDEOJS": "مشغل Video.js",
"PLAYER_ARTPLAYER": "ArtPlayer",
"PLAYER_EMBEDDED_MPV": "MPV المضمّن (تجريبي، macOS)",
"PLAYER_MPV": "مشغل MPV",
"PLAYER_VLC": "VLC",
"VIDEO_PLAYER_PLACEHOLDER": "اختر خيارًا",
"VERSION": "الإصدار",
"SAVE_CHANGES": "حفظ التغييرات",
"BACK_TO_HOME": "العودة للرئيسية",
"NEW_VERSION_AVAILABLE": "إصدار جديد متاح",
"LATEST_VERSION": "أنت تستخدم الإصدار الأحدث",
"STREAM_FORMAT": "تنسيق البث",
"LANGUAGE": "اللغة",
"SETTINGS_SAVED": "نجاح! تم حفظ الإعدادات.",
"THEME": "السمة البصرية",
"SHOW_CAPTIONS": "عرض الترجمات",
"SHOW_DASHBOARD": "عرض لوحة التحكم",
"SHOW_DASHBOARD_DESCRIPTION": "إبقاء لوحة التحكم متاحة في شريط مساحة العمل. عند التعطيل، تصبح المصادر هي أول صفحة في مساحة العمل.",
"ADD_EPG_SOURCE": "إضافة مصدر EPG",
"CLEAR_EPG_DATA": "مسح بيانات EPG",
"CLEAR_EPG_DIALOG": {
"TITLE": "مسح بيانات EPG",
"MESSAGE": "هل أنت متأكد من رغبتك في إزالة جميع بيانات EPG (الدليل الإلكتروني للبرامج) من قاعدة البيانات؟ سيؤدي ذلك إلى حذف جميع جداول البرامج المخزنة مؤقتًا."
},
"EPG_DATA_CLEARED": "تم مسح بيانات EPG",
"EPG_DATA_CLEAR_FAILED": "فشل مسح بيانات EPG. يرجى المحاولة مرة أخرى.",
"CLEARING_EPG_DATA": "جارٍ المسح…",
"EPG_URL_ERROR": "القيمة ليست رابطًا صالحًا",
"VIDEO_PLAYER_DESCRIPTION": "اختر مشغل الفيديو الافتراضي",
"STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)",
"LANGUAGE_DESCRIPTION": "اختر لغة التطبيق",
"THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق",
"COVER_SIZE": "حجم الغلاف",
"COVER_SIZE_DESCRIPTION": "اضبط حجم بطاقات الملصقات في الشبكات والصفوف. الأحجام الأكبر أسهل للرؤية على شاشات التلفاز؛ والأحجام الأصغر تعرض محتوى أكثر في كل صف على الشاشات الكبيرة.",
"COVER_SIZE_SMALL": "صغير",
"COVER_SIZE_MEDIUM": "متوسط",
"COVER_SIZE_LARGE": "كبير",
"STARTUP_BEHAVIOR": "عرض البدء",
"STARTUP_BEHAVIOR_DESCRIPTION": "اختر ما إذا كان IPTVnator يفتح أول عرض متاح في مساحة العمل أو يستعيد آخر عرض على مستوى القسم. العرض الأول يعني لوحة التحكم عند تفعيلها، وإلا فالمصادر.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متاح",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "استعادة آخر عرض",
"SHOW_CAPTIONS_DESCRIPTION": "عرض أو إخفاء الترجمات افتراضيًا",
"SHOW_EXTERNAL_PLAYBACK_BAR": "عرض شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "إبقاء شريط حالة المشغل الخارجي مرئيًا في أسفل مساحة العمل على سطح المكتب.",
"VERSION_DESCRIPTION": "الإصدار الحالي للتطبيق",
"ABOUT_SUBTITLE": "الإصدار وروابط المشروع وطرق دعم IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "بيئة تشغيل MPV المضمّنة",
"EMBEDDED_MPV_NOTICE_DESCRIPTION": "يمكن أن تتضمن إصدارات macOS مكتبتي libmpv و FFmpeg المرفقتين لمشغل MPV المضمّن التجريبي. لا يحتاج المستخدمون إلى تثبيت تطبيق MPV بشكل منفصل؛ ويبقى MPV و VLC الخارجيان متاحين كمشغلين احتياطيين.",
"COMMUNITY": "المشروع والمجتمع",
"COMMUNITY_DESCRIPTION": "ضع نجمة على المستودع، أو اقرأ المدونة، أو انضم إلى مجموعة Telegram للحصول على التحديثات والمساعدة.",
"LINK_BLOG": "المدونة",
"LINK_GITHUB_DESCRIPTION": "ضع نجمة على المستودع، أو افتح مشكلة، أو ساهم في المشروع",
"LINK_BLOG_DESCRIPTION": "ملاحظات الإصدار، والنصائح، وتحديثات المشروع",
"LINK_TELEGRAM_DESCRIPTION": "الإعلانات والمجتمع والدعم",
"SUPPORT_TITLE": "ادعم المشروع",
"SUPPORT_DESCRIPTION": "IPTVnator تطبيق مجاني ومفتوح المصدر، وتم بناؤه بشكل مفتوح. إذا كان جزءًا من روتينك اليومي، فإن أي مساهمة صغيرة تساعد في إبقاء المشروع حيًا وتحديد ما يأتي لاحقًا — شكرًا لك.",
"SUPPORT_SPONSORS_HINT": "متكرر أو لمرة واحدة",
"SUPPORT_KOFI_HINT": "اشتري لي قهوة",
"EPG_NOTE": "الـ EPG متاح فقط في نسخة Electron من التطبيق.",
"EPG_NOTE_URL_TEXT": "تحقق من هنا",
"IMPORT_EXPORT_DATA": "استيراد/تصدير القوائم",
"IMPORT_EXPORT_DATA_DESCRIPTION": "تصدير أو استيراد القوائم بتنسيق JSON.",
"IMPORT_DATA": "استيراد",
"EXPORT_DATA": "تصدير",
"EXPORTING_DATA": "جارٍ التصدير…",
"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.",
@@ -200,52 +320,123 @@
"MPV_PLAYER_PATH_LABEL": "مسار مشغل MPV",
"MPV_PLAYER_PATH_DESCRIPTION": "حدد مسار مشغل MPV في نظامك.",
"MPV_PLAYER_PATH": "مسار مشغل MPV",
"MPV_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة MPV",
"MPV_REUSE_INSTANCE_DESCRIPTION": "عند التفعيل، سيتم إعادة استخدام نفس نافذة MPV لتشغيل البثوث المختلفة. عند التعطيل، يفتح كل بث في نافذة MPV جديدة.",
"VLC_PLAYER_PATH_LABEL": "مسار VLC",
"VLC_PLAYER_PATH_DESCRIPTION": "حدد مسار مشغل VLC في نظامك.",
"VLC_PLAYER_PATH": "مسار مشغل VLC",
"VLC_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة VLC",
"VLC_REUSE_INSTANCE_DESCRIPTION": "عند التفعيل، سيتم إعادة استخدام نفس نافذة VLC لتشغيل البثوث المختلفة. عند التعطيل، يفتح كل بث في نافذة VLC جديدة.",
"REMOTE_CONTROL": "التحكم عن بعد",
"REMOTE_CONTROL_DESCRIPTION": "تمكين التحكم عن بعد للتطبيق.",
"REMOTE_CONTROL_PORT": "منفذ التحكم عن بعد",
"REMOTE_CONTROL_PORT_DESCRIPTION": "المنفذ المستخدم للتحكم عن بعد (الافتراضي: 8765).",
"REMOTE_CONTROL_URL": "رابط التحكم عن بعد",
"REMOTE_CONTROL_URL_DESCRIPTION": "الوصول إلى التحكم عن بعد من جهاز آخر على نفس الشبكة.",
"SHOW_QR_CODE": "عرض رمز QR",
"STATUS_ENABLED": "مفعّل",
"STATUS_DISABLED": "معطّل",
"SOURCE_SINGULAR": "مصدر",
"SOURCE_PLURAL": "مصادر",
"PORT_RANGE_LABEL": "المنفذ (1-65535)",
"PORT_REQUIRED": "المنفذ مطلوب",
"PORT_MIN": "يجب أن يكون المنفذ 1 على الأقل",
"PORT_MAX": "يجب ألا يتجاوز المنفذ 65535",
"PORT_PATTERN": "الأرقام فقط مسموح بها",
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset"
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "السمة الداكنة",
"LIGHT_THEME": "السمة الفاتحة",
"SYSTEM_THEME": "سمة النظام"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "البحث عن قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
"MANAGE_GROUPS": "إدارة المجموعات",
"MANAGE_GROUPS_HINT": "استخدم إدارة المجموعات لإظهار المجموعات مرة أخرى.",
"NO_VISIBLE_GROUPS": "لا توجد مجموعات مرئية",
"SEARCH_GROUPS": "بحث في المجموعات",
"UNGROUPED": "غير مصنفة",
"UNNAMED_CHANNEL": "قناة بدون اسم",
"FAVORITES": "المفضلة",
"NO_FAVORITES": "لا توجد قنوات مفضلة",
"USE_STAR_TO_FAVORITE": "استخدم أيقونة النجمة لإضافة قنوات للمفضلة.",
"REMOVE_FAVORITE": "إزالة من المفضلة",
"ADD_FAVORITE": "إضافة إلى المفضلة",
"SHOW_DETAILS": "عرض تفاصيل القناة",
"FAVORITES_UPDATED": "تم تحديث المفضلة!",
"SELECT_CHANNEL_PLAYBACK": "يرجى اختيار قناة لبدء التشغيل",
"SORT_BY": "ترتيب حسب",
"SORT_BY_DATE": "تاريخ الإضافة (الأحدث أولاً)",
"SORT_BY_RATING": "التقييم",
"SORT_BY_NAME": "الاسم (أ-ي)"
"SORT_BY_NAME": "الاسم (أ-ي)",
"LOAD_MORE": "تحميل المزيد",
"REMAINING": "متبقية",
"LOADING": "جارٍ تحميل القنوات...",
"LOADING_MORE": "جارٍ تحميل المزيد...",
"NO_SEARCH_RESULTS": "لم يتم العثور على قنوات",
"NO_CHANNELS_FOUND": "لم يتم العثور على قنوات",
"NO_GROUPS_FOUND": "لم يتم العثور على مجموعات",
"TRY_DIFFERENT_SEARCH": "جرّب مصطلح بحث مختلف",
"DETAILS_DIALOG": {
"TITLE": "تفاصيل القناة",
"DESCRIPTION": "بيانات وصفية للقراءة فقط مستخرجة من ملف قائمة التشغيل.",
"SUMMARY": "ملخص",
"TVG": "بيانات TVG",
"CATCHUP": "Catchup",
"HTTP": "ترويسات HTTP",
"NAME": "الاسم",
"CHANNEL_ID": "معرّف القناة",
"STREAM_URL": "رابط البث",
"GROUP": "المجموعة",
"RADIO": "راديو",
"CATCHUP_AVAILABLE": "Catchup متاح",
"WINDOW": "نافذة الأرشيف",
"PLAYBACK_SUPPORTED": "التشغيل مدعوم",
"EPG_PARAMS": "معاملات EPG",
"TIMESHIFT": "Timeshift",
"TVG_ID": "معرّف TVG",
"TVG_NAME": "اسم TVG",
"TVG_URL": "رابط TVG",
"TVG_LOGO": "شعار TVG",
"TVG_REC": "تسجيل TVG",
"CATCHUP_TYPE": "نوع Catchup",
"CATCHUP_SOURCE": "مصدر Catchup",
"CATCHUP_DAYS": "أيام Catchup",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "غير متوفر",
"NOT_AVAILABLE": "غير متاح",
"DAYS_ONE": "يوم واحد",
"DAYS_OTHER": "{{count}} يوم"
},
"GROUP_MANAGEMENT": {
"TITLE": "إدارة المجموعات",
"SELECTED": "محدد",
"SELECT_ALL": "تحديد الكل",
"DESELECT_ALL": "إلغاء تحديد الكل",
"SEARCH_PLACEHOLDER": "بحث في المجموعات...",
"NO_RESULTS": "لم يتم العثور على مجموعات مطابقة",
"SAVE": "حفظ"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "فتح قائمة القنوات",
"TOGGLE_FAVORITE_FLAG": "تبديل حالة المفضلة",
"OPEN_EPG_LIST": "فتح قائمة EPG",
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG"
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG",
"SHOW_INFO": "عرض معلومات البرنامج (اضغط I)"
},
"EPG": {
"TITLE": "الدليل الإلكتروني للبرامج",
@@ -254,12 +445,16 @@
"LIVE_NOW": "يُبث الآن",
"LIVE_STREAM": "بث مباشر",
"TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف غير متاح",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "يكشف هذا المزود عن سجل الجدول لهذه القناة، لكنه لا يوفر تشغيلًا قابلًا للأرشيف أو Catchup.",
"EPG_NOT_AVAILABLE_DATE": "عذراً، الدليل غير متاح لهذا التاريخ",
"NO_GUIDE_HINT": "جرب تاريخاً آخر أو حدّث قائمة التشغيل.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "عذراً، الدليل غير متاح لهذه القناة",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "يرجى إضافة/تعديل رابط EPG في إعدادات التطبيق.",
"FETCH_EPG": "جارٍ جلب بيانات EPG...",
"ERROR": "عذراً، فشل تحميل الدليل.",
"DOWNLOAD_SUCCESS": "تم تنزيل الدليل بنجاح.",
"NO_PROGRAM_INFO": "لا تتوفر معلومات عن البرنامج",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "تفاصيل البرنامج",
"TITLE": "العنوان",
@@ -267,9 +462,23 @@
"PARENTAL_RATING_SYSTEM": "نظام تقييم المحتوى",
"DESCRIPTION": "الوصف",
"LANGUAGE": "اللغة",
"SHOW_PROGRAM_DETAILS": "عرض تفاصيل البرنامج"
"SHOW_PROGRAM_DETAILS": "عرض تفاصيل البرنامج",
"EPISODE": "الحلقة",
"DURATION": "المدة"
},
"FETCH_SUCCESS": "تم جلب الدليل بنجاح."
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
"IMPORT_PROGRESS": "استيراد EPG",
"CHANNELS": "قنوات",
"PROGRAMS": "برامج",
"LOADING": "جارٍ التحميل",
"QUEUED": "في قائمة الانتظار",
"TOTAL_CHANNELS": "إجمالي القنوات",
"TOTAL_PROGRAMS": "إجمالي البرامج",
"UP_TO_DATE": "EPG محدث، لا حاجة للمزامنة",
"RETRY": "إعادة المحاولة",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -316,6 +525,7 @@
"REFRESH": "تحديث",
"XTREAM": {
"PLAY": "تشغيل",
"OPENING_PLAYER": "جارٍ فتح المشغل...",
"NAME": "الاسم",
"RELEASE_DATE": "تاريخ الإصدار",
"GENRE": "النوع",
@@ -332,7 +542,59 @@
"YEAR": "السنة",
"AGE": "العمر",
"YOUTUBE_TRAILER": "تريلر يوتيوب",
"EPISODE": "الحلقة"
"EPISODE": "الحلقة",
"RESUME": "استئناف",
"RESTART": "البدء من جديد",
"CONTINUE_WATCHING": "متابعة المشاهدة",
"WATCHED": "تمت المشاهدة",
"IN_PROGRESS": "قيد التقدم",
"MARK_WATCHED": "وضع علامة كمشاهد",
"MARK_UNWATCHED": "وضع علامة كغير مشاهد",
"EPISODES_WATCHED": "تمت مشاهدة {{count}} من {{total}} حلقة",
"DETAIL_FALLBACK": {
"STATUS": "البيانات الوصفية للبوابة غير متاحة",
"NOTE": "لم يقدم هذا البوابة بيانات وصفية موسّعة.",
"STREAM_ID": "معرّف البث",
"CATEGORY": "الفئة",
"CONTAINER": "التنسيق",
"SOURCE_TITLE": "عنوان المصدر"
},
"ACCOUNT_INFO": {
"TITLE": "معلومات الحساب",
"USER_INFO": "معلومات المستخدم",
"STATUS": "الحالة",
"USERNAME": "اسم المستخدم",
"ACTIVE_CONNECTIONS": "الاتصالات النشطة",
"CREATED": "تاريخ الإنشاء",
"EXPIRES": "تاريخ الانتهاء",
"TRIAL_ACCOUNT": "حساب تجريبي",
"YES": "نعم",
"NO": "لا",
"ALLOWED_FORMATS": "التنسيقات المسموح بها",
"SERVER_INFO": "معلومات الخادم",
"LIVE_TV": "البث المباشر",
"MOVIES": "الأفلام",
"TV_SERIES": "المسلسلات",
"URL": "URL",
"PROTOCOL": "البروتوكول",
"TIMEZONE": "المنطقة الزمنية",
"SERVER_TIME": "وقت الخادم",
"PORTS": "المنافذ",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
},
"CATEGORY_MANAGEMENT": {
"TITLE": "إدارة الفئات",
"LOADING": "جارٍ تحميل الفئات...",
"SELECTED": "محدد",
"SELECT_ALL": "تحديد الكل",
"DESELECT_ALL": "إلغاء تحديد الكل",
"SEARCH_PLACEHOLDER": "بحث في الفئات...",
"NO_RESULTS": "لم يتم العثور على فئات مطابقة",
"NO_CATEGORIES": "لا توجد فئات متاحة",
"SAVE": "حفظ"
}
},
"PORTALS": {
"ALL_CATEGORIES": "جميع الفئات",
@@ -341,11 +603,47 @@
"REMOVE_FROM_FAVORITES": "إزالة من المفضلة",
"REMOVED_FROM_FAVORITES": "تمت الإزالة من المفضلة",
"ADDED_TO_FAVORITES": "تمت الإضافة للمفضلة",
"FAVORITES": "المفضلة",
"RECENTLY_VIEWED": "تمت مشاهدتها مؤخرًا",
"THIS_PLAYLIST": "قائمة التشغيل هذه",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"LIVE_TV": "البث المباشر",
"MOVIES": "الأفلام",
"SERIES": "المسلسلات",
"SEARCH": "بحث",
"NO_FAVORITES": "لا توجد مفضلة بعد",
"NO_RECENT_ITEMS": "لا توجد عناصر تمت مشاهدتها مؤخرًا",
"REMOVE_FROM_RECENT": "إزالة من العناصر التي تمت مشاهدتها مؤخرًا",
"BROWSE_ALL_MOVIES": "تصفح جميع الأفلام",
"BROWSE_ALL_SERIES": "تصفح جميع المسلسلات",
"BROWSE_ALL_LIVE": "تصفح جميع قنوات البث المباشر",
"SIDEBAR": {
"MAIN": "الرئيسية",
"MOVIES": "الأفلام",
"LIVE_TV": "البث المباشر",
"SERIES": "المسلسلات",
"LIBRARY": "المكتبة",
"SEARCH": "بحث",
"RECENT": "الأخيرة",
"RECENTLY_ADDED": "أُضيفت مؤخرًا",
"FAVORITES": "المفضلة",
"HOME": "الرئيسية",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
"TITLE": "تم إلغاء الاستيراد",
"DESCRIPTION": "تم إلغاء استيراد قائمة التشغيل. أعد المحاولة عندما تريد متابعة تحميل هذا المصدر."
},
"ACCOUNT_EXPIRED": {
"TITLE": "انتهاء صلاحية الحساب",
"DESCRIPTION": "انتهت صلاحية هذا الحساب. يرجى التحقق من بيانات الاعتماد في إعدادات القائمة أو حذفها."
},
"ACCOUNT_INACTIVE": {
"TITLE": "الحساب غير نشط",
"DESCRIPTION": "هذا الحساب غير نشط. يرجى التحقق من بيانات الاعتماد في إعدادات قائمة التشغيل أو حذفها."
},
"UNAUTHORIZED": {
"TITLE": "غير مصرح",
"DESCRIPTION": "يرجى التحقق من بيانات الاعتماد في إعدادات القائمة أو حذفها."
@@ -354,6 +652,10 @@
"TITLE": "البوابة غير موجودة",
"DESCRIPTION": "يرجى التحقق من بيانات الاعتماد ورابط البوابة في إعدادات القائمة أو حذفها."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "البوابة غير متاحة",
"DESCRIPTION": "تعذر الوصول إلى البوابة. تحقق من رابط الخادم أو أعد المحاولة عندما يصبح المصدر متاحًا مرة أخرى."
},
"EMPTY_CATEGORY": {
"TITLE": "فئة فارغة",
"DESCRIPTION": "هذه الفئة لا تحتوي على محتوى، يرجى اختيار فئة أخرى أو تغيير نوع المحتوى."
@@ -362,30 +664,173 @@
"TITLE": "حدث خطأ",
"DESCRIPTION": "خطأ غير معروف، يرجى المحاولة لاحقًا."
},
"NO_CATEGORY_SELECTED": {
"TITLE": "لم يتم تحديد فئة",
"DESCRIPTION": "يرجى اختيار فئة لعرض المحتوى"
},
"PLAYLIST_SETTINGS": "إعدادات القائمة",
"HOME": "الرئيسية",
"DELETE": "حذف"
},
"EMPTY_LIST_VIEW": {
"TITLE": "لم يتم العثور على نتائج",
"NO_SEARCH_RESULTS": "لم يتم العثور على نتائج، حاول تغيير طلب البحث"
},
"SEARCH_VIEW": {
"INITIAL_TITLE": "البحث عن محتوى",
"INITIAL_DESCRIPTION": "أدخل ٣ أحرف على الأقل للبحث في البث المباشر والأفلام والمسلسلات.",
"RESULTS_FOUND": "تم العثور على {{count}} نتيجة",
"NO_RESULTS_FOR": "لم يتم العثور على نتائج لـ \"{{term}}\"",
"GROUP_BY_PLAYLIST": "تجميع النتائج حسب قائمة التشغيل",
"XTREAM_ONLY_NOTE": "البحث الشامل متاح حاليًا لقوائم Xtream فقط.",
"VISIBLE_CATEGORIES_ONLY": "الفئات المرئية فقط"
},
"STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة",
"COPY_STREAM_URL": "نسخ رابط البث",
"SEARCH_VIEW": {
"RESULTS_FOUND": "تم العثور على {{count}} نتيجة",
"NO_RESULTS_FOR": "لم يتم العثور على نتائج لـ \"{{term}}\""
}
"SELECT_CATEGORY": "اختر فئة",
"PAGE": "الصفحة",
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
"PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل",
"LOADING_SEASONS": "جارٍ تحميل المواسم...",
"NO_EPISODES": "لا توجد حلقات متاحة",
"ALL_RADIO": "كل الراديو"
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
"TITLE": "التنزيلات",
"DOWNLOAD": "تنزيل",
"PLAY_LOCAL": "تشغيل محلي",
"EMPTY": "لا توجد تنزيلات بعد",
"EMPTY_HINT": "نزّل الأفلام والحلقات للمشاهدة دون اتصال",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term",
"NOT_AVAILABLE": "التنزيلات غير متاحة",
"DESKTOP_ONLY": "هذه الميزة متاحة فقط في تطبيق سطح المكتب",
"NO_FOLDER": "لم يتم تحديد مجلد التنزيل",
"CHANGE_FOLDER": "تغيير المجلد",
"CLEAR_COMPLETED": "مسح المكتملة",
"CLEAR_COMPLETED_DIALOG": {
"TITLE": "مسح التنزيلات المكتملة",
"MESSAGE": "يؤدي هذا إلى إزالة الإدخالات المكتملة والفاشلة والملغاة من عرض التنزيلات الحالي. ولا يحذف أي ملفات تم تنزيلها فعلًا على القرص."
},
"SOURCE_PLAYLIST_MISSING": "لم يعد بالإمكان فتح هذا التنزيل في المكتبة لأنه تمت إزالة قائمة التشغيل المصدر.",
"FILE_NOT_FOUND": "هذا الملف الذي تم تنزيله لم يعد متاحًا على القرص.",
"FILE_ACTION_ERROR": "تعذر إكمال الإجراء على الملف.",
"CANCEL": "إلغاء",
"RETRY": "إعادة المحاولة",
"REMOVE": "إزالة",
"PLAY": "تشغيل",
"REVEAL": "عرض في المجلد",
"COPY_URL": "نسخ رابط التنزيل",
"URL_COPIED": "تم نسخ رابط التنزيل إلى الحافظة",
"URL_COPY_FAILED": "فشل نسخ الرابط إلى الحافظة",
"STATUS": {
"QUEUED": "في قائمة الانتظار",
"DOWNLOADING": "جارٍ التنزيل",
"COMPLETED": "مكتمل",
"FAILED": "فشل",
"CANCELED": "ملغى"
}
},
"REMOTE_CONTROL": {
"TITLE": "التحكم عن بعد",
"SUBTITLE": "تحكم في مشغل IPTV عن بعد",
"UP_CHANNEL": "القناة السابقة",
"DOWN_CHANNEL": "القناة التالية",
"HEADER": "التحكم عن بعد في IPTVNator",
"FOOTER": "التحكم عن بعد في IPTVNator"
},
"WORKSPACE": {
"FAVORITES_ONLY": "المفضلة فقط",
"SORT_CUSTOM": "ترتيب مخصص",
"SORT_DATE_DESC": "تاريخ الإضافة (الأحدث أولاً)",
"SORT_DATE_ASC": "تاريخ الإضافة (الأقدم أولاً)",
"SORT_NAME_ASC": "الاسم (أ-ي)",
"SORT_NAME_DESC": "الاسم (ي-أ)",
"SORT_LABEL": "ترتيب: ",
"DASHBOARD": {
"CONTINUE_WATCHING": "متابعة المشاهدة",
"UNTITLED_SOURCE": "مصدر بدون عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "إزالة من السجل",
"RECENT_SOURCES": "المصادر المستخدمة مؤخرًا",
"MANAGE_ALL": "إدارة الكل",
"RECENTLY_WATCHED": "ما تمت مشاهدته مؤخرًا",
"XTREAM_RECENTLY_ADDED": "أُضيفت مؤخرًا على Xtream",
"NO_HISTORY": "لم يتم العثور على عناصر لهذا الفلتر.",
"NO_HISTORY_HINT": "سيظهر سجل المشاهدة هنا بمجرد بدء تشغيل محتوى من قوائم التشغيل الخاصة بك.",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U",
"STALKER_PORTAL": "Stalker Portal",
"PROVIDER": "المزود",
"TYPE_LIVE": "مباشر",
"TYPE_MOVIE": "فيلم",
"TYPE_SERIES": "مسلسل",
"NOT_YET_SYNCED": "لم تتم المزامنة بعد"
},
"SHELL": {
"BRAND_ALT": "IPTVnator",
"RAIL_DASHBOARD": "لوحة التحكم",
"OPEN_DASHBOARD": "فتح لوحة التحكم",
"RAIL_SOURCES": "المصادر",
"OPEN_SOURCES": "فتح المصادر",
"RAIL_GLOBAL_FAVORITES": "المفضلة",
"RAIL_GLOBAL_RECENT": "تمت مشاهدتها مؤخرًا",
"RAIL_SETTINGS": "الإعدادات",
"OPEN_SETTINGS": "فتح الإعدادات",
"RAIL_LIBRARY": "المكتبة",
"RAIL_MOVIES": "الأفلام",
"RAIL_LIVE": "البث المباشر",
"RAIL_SERIES": "المسلسلات",
"RAIL_RECENTLY_ADDED": "أُضيفت مؤخرًا",
"RAIL_SEARCH": "بحث متقدم",
"RAIL_RECENT": "تمت مشاهدتها مؤخرًا",
"RAIL_FAVORITES": "المفضلة",
"RAIL_DOWNLOADS": "التنزيلات",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"RAIL_ALL_CHANNELS": "جميع القنوات",
"RAIL_GROUPS": "المجموعات",
"RAIL_CONTEXT_ACTIONS": "إجراءات القسم",
"SEARCH_LABEL": "بحث",
"SEARCH_SOURCES_PLACEHOLDER": "البحث في المصادر (جميع قوائم التشغيل)...",
"SEARCH_PLAYLIST_PLACEHOLDER": "البحث في قائمة التشغيل هذه...",
"SEARCH_SECTION_PLACEHOLDER": "البحث في هذا القسم...",
"FILTER_SECTION_PLACEHOLDER": "تصفية هذا القسم...",
"SEARCH_STATUS_LOADED_ONLY": "القنوات المحمّلة فقط",
"COMMAND_PALETTE_SHORTCUT": "لوحة الأوامر (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "فتح لوحة الأوامر",
"ADD_PLAYLIST_ALL": "إضافة قائمة تشغيل",
"ADD_PLAYLIST": "إضافة قائمة تشغيل",
"OPEN_DOWNLOADS": "فتح التنزيلات",
"OPEN_CONTEXT_ACTIONS": "فتح إجراءات القسم",
"NO_SECTION_ACTIONS": "لا توجد إجراءات لهذا القسم",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب",
"REFRESH_PLAYLIST": "تحديث قائمة التشغيل",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "مسح التنزيلات المكتملة (جميع قوائم التشغيل)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "مسح التنزيلات المكتملة لجميع قوائم التشغيل",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "مسح التنزيلات المكتملة (قائمة التشغيل هذه)",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "مسح التنزيلات المكتملة لقائمة التشغيل هذه",
"CLEAR_RECENTLY_VIEWED_SECTION": "مسح ما تمت مشاهدته مؤخرًا",
"CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "مسح ما تمت مشاهدته مؤخرًا لهذا القسم",
"CLEAR_RECENTLY_VIEWED_TYPE": "مسح ما تمت مشاهدته مؤخرًا من {{type}}",
"CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "مسح ما تمت مشاهدته مؤخرًا من {{type}}؟",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "ستتم إزالة جميع عناصر {{type}} من قائمة التشغيل هذه من سجل ما تمت مشاهدته مؤخرًا. لا يمكن التراجع عن هذا الإجراء.",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL": "ستتم إزالة جميع عناصر {{type}} عبر جميع قوائم التشغيل من سجل ما تمت مشاهدته مؤخرًا. لا يمكن التراجع عن هذا الإجراء.",
"CLEAR_FAVORITES_TYPE": "مسح المفضلة من {{type}}",
"CLEAR_FAVORITES_DIALOG_TITLE": "إزالة جميع المفضلة من {{type}}؟",
"CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST": "ستتم إزالة جميع المفضلة من {{type}} في قائمة التشغيل هذه. لا يمكن التراجع عن هذا الإجراء.",
"CLEAR_FAVORITES_DIALOG_MESSAGE_ALL": "ستتم إزالة جميع المفضلة من {{type}} عبر جميع قوائم التشغيل. لا يمكن التراجع عن هذا الإجراء.",
"XTREAM_CODE": "Xtream Code",
"STALKER_PORTAL": "Stalker Portal",
"CHANNELS_COUNT": "{{count}} قناة",
"NO_SOURCES_AVAILABLE": "لا توجد مصادر متاحة",
"ONE_SOURCE_AVAILABLE": "مصدر واحد متاح",
"SOURCES_AVAILABLE": "{{count}} مصدر متاح",
"GO_TO_DASHBOARD": "Back to dashboard",
"GO_TO_SOURCES": "Go to Sources",
"DROP_OVERLAY_TITLE": "Drop to add playlist",
@@ -395,21 +840,129 @@
"DROP_OVERLAY_REJECTED_EMPTY": "That file is empty",
"DROP_OVERLAY_REJECTED_READ_ERROR": "Could not read that file",
"DROP_IMPORT_SUCCESS": "Importing \"{{title}}\"…",
"XTREAM_IMPORT_TITLE": "جارٍ مزامنة قائمة التشغيل",
"XTREAM_IMPORT_REMOTE_BADGE": "مصدر بعيد",
"XTREAM_IMPORT_LOCAL_BADGE": "مكتبة محلية",
"XTREAM_IMPORT_DETAIL_REMOTE": "بانتظار استجابة المزود.",
"XTREAM_IMPORT_DETAIL_LOCAL": "تحضير تبديل أسرع لقائمة التشغيل في الزيارة القادمة.",
"XTREAM_IMPORT_PREPARING": "جارٍ تحضير المكتبة المحلية...",
"XTREAM_IMPORT_LOADING": "جارٍ جلب بيانات قائمة التشغيل من المصدر...",
"XTREAM_IMPORT_SAVING": "جارٍ حفظ بيانات قائمة التشغيل محليًا...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "جارٍ استعادة المفضلة من المكتبة المحلية...",
"XTREAM_IMPORT_RESTORING_RECENT": "جارٍ استعادة ما تمت مشاهدته مؤخرًا من المكتبة المحلية...",
"XTREAM_IMPORT_PROGRESS": "{{type}} المستوردة: {{current}} / {{total}}",
"XTREAM_IMPORT_CANCEL": "إيقاف المزامنة",
"XTREAM_IMPORT_CANCELLING": "جارٍ إيقاف المزامنة...",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "البحث في جميع قوائم Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "فتح طبقة البحث الشامل",
"PLAYLIST_SEARCH_LABEL": "بحث متقدم في قائمة التشغيل هذه",
"PLAYLIST_SEARCH_DESCRIPTION": "فتح البحث المتقدم في قائمة التشغيل",
"OPEN_GLOBAL_FAVORITES_LABEL": "فتح المفضلة العامة",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "الانتقال إلى المفضلة المجمّعة",
"OPEN_DOWNLOADS_LABEL": "فتح التنزيلات",
"OPEN_DOWNLOADS_DESCRIPTION": "الانتقال إلى عرض التنزيلات",
"OPEN_GLOBAL_RECENT_LABEL": "فتح ما تمت مشاهدته مؤخرًا",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "فتح صفحة ما تمت مشاهدته مؤخرًا العامة",
"OPEN_SETTINGS_LABEL": "فتح الإعدادات",
"OPEN_SETTINGS_DESCRIPTION": "الانتقال إلى إعدادات التطبيق",
"OPEN_SOURCES_LABEL": "إدارة المصادر",
"OPEN_SOURCES_DESCRIPTION": "الانتقال إلى إدارة المصادر",
"OPEN_DASHBOARD_LABEL": "فتح لوحة التحكم",
"OPEN_DASHBOARD_DESCRIPTION": "الانتقال إلى لوحة التحكم",
"ADD_PLAYLIST_LABEL": "إضافة مصدر جديد",
"ADD_PLAYLIST_DESCRIPTION": "فتح مربع حوار إضافة قائمة تشغيل",
"ADD_PLAYLIST_M3U_LABEL": "Add M3U playlist",
"ADD_PLAYLIST_M3U_DESCRIPTION": "Open add dialog on the M3U file or URL tab",
"ADD_PLAYLIST_XTREAM_LABEL": "Add Xtream Codes playlist",
"ADD_PLAYLIST_XTREAM_DESCRIPTION": "Open add dialog on the Xtream Codes tab",
"ADD_PLAYLIST_STALKER_LABEL": "Add Stalker portal",
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab"
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab",
"REFRESH_PLAYLIST_LABEL": "تحديث قائمة التشغيل",
"REFRESH_PLAYLIST_DESCRIPTION": "إعادة مزامنة البيانات من المصدر البعيد",
"PLAYLIST_INFO_LABEL": "تفاصيل قائمة التشغيل",
"PLAYLIST_INFO_DESCRIPTION": "عرض معلومات قائمة التشغيل الحالية",
"ACCOUNT_INFO_LABEL": "معلومات الحساب",
"ACCOUNT_INFO_DESCRIPTION": "عرض تفاصيل حساب خادم Xtream",
"OPEN_VIEW_DESCRIPTION": "الانتقال إلى {{view}}",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "مسح جميع عناصر {{type}} المرئية في هذا العرض",
"OPEN_MULTI_EPG_DESCRIPTION": "فتح عرض EPG متعدد القنوات",
"GO_TO_VOD_LABEL": "الأفلام",
"GO_TO_VOD_DESCRIPTION": "الانتقال إلى قسم الأفلام",
"GO_TO_LIVE_LABEL": "البث المباشر",
"GO_TO_LIVE_DESCRIPTION": "الانتقال إلى قسم البث المباشر",
"GO_TO_SERIES_LABEL": "المسلسلات",
"GO_TO_SERIES_DESCRIPTION": "الانتقال إلى قسم المسلسلات",
"SWITCH_PLAYER_LABEL": "تبديل المشغل إلى {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "تعيين {{name}} كمشغل الفيديو النشط",
"SWITCH_PLAYER_FEEDBACK": "تم تعيين المشغل إلى {{name}}"
},
"EXTERNAL_PLAYBACK_CLOSE": "Close player"
"XTREAM_REFRESH_TITLE": "جارٍ تحديث قائمة التشغيل",
"XTREAM_REFRESH_COLLECTING_DATA": "جارٍ حفظ بيانات مكتبتك...",
"XTREAM_REFRESH_DELETING_CONTENT": "جارٍ إزالة البث المخزّن مؤقتًا...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "جارٍ إزالة الفئات المخزّنة مؤقتًا...",
"XTREAM_REFRESH_DETAIL_LOCAL": "نحتفظ بالمفضلة وسجل المشاهدة والفئات المخفية وتقدم التشغيل لاستعادتها بعد المزامنة.",
"XTREAM_REFRESH_PROGRESS": "السجلات المحلية المعالجة: {{current}} / {{total}}",
"RAIL_RADIO": "راديو"
},
"SOURCES": {
"TITLE": "المصادر",
"SUBTITLE": "تصفية وفرز قوائم التشغيل",
"TYPE": "النوع",
"ALL": "الكل",
"M3U_PLAYLISTS": "قوائم M3U",
"XTREAM_PLAYLISTS": "قوائم Xtream",
"STALKER_PLAYLISTS": "قوائم Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل واحدة",
"PLAYLIST_COUNT_OTHER": "{{count}} قائمة تشغيل"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
"MOVIE_CATEGORIES": "فئات الأفلام",
"SERIES_CATEGORIES": "فئات المسلسلات",
"LIVE_CATEGORIES": "فئات البث المباشر",
"XTREAM_SYNCING_MOVIES": "جارٍ مزامنة فئات الأفلام...",
"XTREAM_SYNCING_SERIES": "جارٍ مزامنة فئات المسلسلات...",
"XTREAM_SYNCING_LIVE": "جارٍ مزامنة فئات البث المباشر...",
"XTREAM_SOURCE": "مصدر Xtream",
"STALKER_PORTAL": "بوابة Stalker",
"MANAGE_CATEGORIES": "إدارة الفئات",
"LOAD_CATEGORIES_ERROR": "تعذر تحميل الفئات",
"LOAD_CATEGORIES_ERROR_HINT": "يرجى إعادة المحاولة أو التحقق من إعدادات البوابة.",
"FILTER_BY_TYPE": "التصفية حسب النوع",
"ITEM_COUNT_ONE": "عنصر واحد",
"ITEM_COUNT_OTHER": "{{count}} عنصر",
"SEARCH": "بحث",
"SEARCH_CATEGORIES": "البحث في الفئات",
"RADIO_CATEGORIES": "تصنيفات الراديو"
},
"COMMAND_PALETTE": {
"TITLE": "لوحة الأوامر",
"SUBTITLE": "إجراءات عامة وسياقية",
"SEARCH_PLACEHOLDER": "اكتب لتصفية الأوامر",
"SEARCH_LABEL": "تصفية الأوامر",
"EMPTY": "لم يتم العثور على أوامر",
"GROUP_GLOBAL": "الإجراءات العامة",
"GROUP_PLAYLIST": "قائمة التشغيل هذه",
"GROUP_VIEW": "العرض الحالي",
"GROUP_SECTION": "هذا القسم",
"GROUP_RECENT": "المستخدمة مؤخرًا"
},
"GLOBAL_FAVORITES": {
"TITLE": "المفضلة العامة",
"SUBTITLE": "البث المباشر من جميع قوائم التشغيل",
"SEARCH_PLACEHOLDER": "البحث في المفضلة...",
"NO_RESULTS": "لا توجد نتائج",
"NO_RESULTS_HINT": "جرّب مصطلح بحث مختلف",
"EMPTY_TITLE": "لا توجد مفضلة من البث المباشر بعد",
"EMPTY_HINT": "ضع نجمة على القنوات المباشرة من قوائم M3U أو Xtream أو Stalker لرؤيتها هنا.",
"EMPTY_PLAYER": "اختر قناة لبدء المشاهدة",
"NO_PLAYLISTS_TITLE": "No favorites to show",
"NO_PLAYLISTS_BODY": "Add a playlist to start collecting favorites from live channels, movies, and series.",
"NO_ITEMS_TITLE": "No favorites yet",
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here."
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here.",
"UNKNOWN_CHANNEL": "غير معروف"
},
"GLOBAL_RECENT": {
"NO_PLAYLISTS_TITLE": "Nothing watched yet",
@@ -417,12 +970,5 @@
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
}
},
"DOWNLOADS": {
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term"
}
}
+617 -63
View File
@@ -1,5 +1,9 @@
{
"CANCEL": "إلغاء",
"CLEAR": "مسح",
"CLOSE": "سد",
"SHOW_MORE": "وري كثر",
"SHOW_LESS": "وري قل",
"PLAYLISTS": "قوائم التشغيل",
"HOME": {
"TITLE": "IPTVnator",
@@ -15,6 +19,44 @@
"PLAYLISTS": {
"NO_PLAYLISTS": "ما تزادت حتى شي قائمة تشغيل",
"ADD_FIRST": "عفاك زيد قائمة التشغيل الأولى ديالك",
"WELCOME_TITLE": "مرحبا بيك في IPTVnator",
"WELCOME_DESCRIPTION": "بدا رحلة البث ديالك بزيادة قائمة التشغيل الأولى ديالك. ربط مع خدمة IPTV المفضلة عندك وتمتع ببث مباشر، أفلام، وسلاسل.",
"ADD_YOUR_FIRST": "زيد قائمة التشغيل الأولى ديالك",
"FEATURE_M3U": "ملفات M3U/M3U8",
"FEATURE_XTREAM": "Xtream Codes",
"FEATURE_STALKER": "Stalker Portal",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"NO_RESULTS_TITLE": "ما تلقاتش حتى قائمة تشغيل",
"NO_RESULTS_DESCRIPTION": "ما كاينش قوائم تشغيل مطابقة للبحث ديالك. جرب تبدل الكلمات ديال البحث ولا مسح الفلتر باش تشوف جميع قوائم التشغيل.",
"ADDED_VIA_URL": "تزادت عن طريق رابط. رابط:",
"ADDED_VIA_FILE": "تزادت عن طريق رفع ملف",
"XTREAM_PLAYLIST": "قائمة تشغيل Xtream",
@@ -45,62 +87,62 @@
"PASSWORD": "كلمة المرور",
"MAC_ADDRESS": "عنوان MAC",
"PLAYLIST_EXPORT_SUCCESS": "قائمة التشغيل تصدرات بنجاح.",
"PLAYLIST_EXPORT_ERROR": "خطأ: تصدير قائمة التشغيل فشل."
"PLAYLIST_EXPORT_ERROR": "خطأ: تصدير قائمة التشغيل فشل.",
"COPY_URL": "نسخ الرابط للحافظة",
"URL_COPIED": "الرابط تنسخ للحافظة"
},
"REMOVE_DIALOG": {
"TITLE": "حيد قائمة التشغيل",
"MESSAGE": "واش متأكد بغيتي تحيد قائمة التشغيل هادي بالكامل؟",
"SUCCESS": "قائمة التشغيل تحيدات بنجاح"
"SUCCESS": "قائمة التشغيل تحيدات بنجاح",
"IN_PROGRESS": "جاري تحييد قائمة التشغيل...",
"DELETING_FAVORITES": "جاري تحييد المفضلة...",
"DELETING_RECENT": "جاري تحييد المشاهدات الأخيرة...",
"DELETING_PROGRESS": "جاري تحييد تقدم التشغيل...",
"DELETING_DOWNLOADS": "جاري تحييد التحميلات...",
"DELETING_CONTENT": "جاري تحييد المحتوى المخزن...",
"DELETING_CATEGORIES": "جاري تحييد الفئات...",
"DELETING_PLAYLIST": "جاري تحييد سجل قائمة التشغيل..."
},
"UPDATED": "تحدثات",
"REFRESH": "حدث قائمة التشغيل",
"REFRESH_XTREAM": "حدث قائمة Xtream من السيرفر",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "حدث قائمة Xtream",
"MESSAGE": "هاد العملية غادي تحيد الذاكرة المخزنة ديال Xtream وغادي تستورد الفئات والقنوات من جديد من السيرفر. المفضلة، المشاهدات الأخيرة، رؤية الفئات المخفية، وتقدم التشغيل غادي يتسترجعو من بعد ما يتم الاستيراد بنجاح إذا المحتوى المطابق مازال موجود. إذا السيرفر ما تلقاش، الاسترجاع غادي يبقى معلق حتى للتحديث المقبل اللي ينجح. واش نكملو؟",
"STARTED": "بدا التحديث. كنوجهك لقائمة التشغيل...",
"ERROR": "فشل تحديث قائمة التشغيل. عفاك حاول مرة أخرى.",
"IN_PROGRESS": "جاري تحديث قائمة التشغيل...",
"COLLECTING_DATA": "جاري جمع المفضلة والمشاهدات الأخيرة...",
"DELETING_CONTENT": "جاري تحييد المحتوى المخزن...",
"DELETING_CATEGORIES": "جاري تحييد الفئات المخزنة..."
},
"GLOBAL_FAVORITES": "المفضلة العامة",
"GLOBAL_FAVORITES_DESCRIPTION": "قائمة تشغيل كتتولد أوتوماتيكياً فيها المفضلة المجمعة من جميع قوائم التشغيل",
"MY_PLAYLISTS": "قوائم التشغيل ديالي",
"MY_PLAYLISTS_SUBTITLE": "جميع قوائم التشغيل المتوفرة",
"PLAYLIST_UPDATE_SUCCESS": "نجاح! قائمة التشغيل تحدثات بنجاح.",
"PLAYLIST_UPDATE_ERROR": "خطأ في تحديث تفاصيل قائمة التشغيل.",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen."
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "فشل تحديث قائمة التشغيل. الملف المحلي ما بقاش متوفر. تحقق من مسار الملف ولا استورد قائمة التشغيل من جديد.",
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "فشل تحديث قائمة التشغيل. التطبيق ما قدرش يوصل للملف المحلي.",
"AUTO_REFRESH_UPDATE_SUCCESS": "نجاح! قوائم التشغيل تحدثات بنجاح",
"AUTO_REFRESH_ENABLED": "التحديث التلقائي مفعل"
},
"FILE_UPLOAD": {
"DRAG_DROP": "جر وأفلت الملفات هنا",
"OR": "أو",
"CHOOSE_PLAYLIST": "اختار قائمة تشغيل",
"BROWSE": "تصفح الملفات",
"REJECTED": "الملف ترفض، صيغة الملف غير مدعومة {{filename}}."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "رابط قائمة التشغيل (m3u, m3u8)",
"ADD_PLAYLIST": "زيد قائمة التشغيل",
"CORS_NOTE": "ملاحظة: باش نتفاداو مشاكل CORS فهاد المرحلة، التطبيق كيستعمل خدمة وكيل CORS عامة. إلى بغيتي ترفع قائمة تشغيل فيها بيانات حساسة، من الأحسن تستوردها من ملف."
"CORS_NOTE": "ملاحظة: باش نتفاداو مشاكل CORS فهاد المرحلة، التطبيق كيستعمل خدمة وكيل CORS عامة. إلى بغيتي ترفع قائمة تشغيل فيها بيانات حساسة، من الأحسن تستوردها من ملف.",
"ERROR_FETCH_FAILED": "فشل جلب قائمة التشغيل. عفاك تحقق من الرابط وحاول مرة أخرى.",
"ERROR_403": "الوصول مرفوض (403): السيرفر رفض الطلب. يمكن الرابط محتاج مصادقة ولا قائمة التشغيل مقيدة.",
"ERROR_404": "قائمة التشغيل ما تلقاتش (404): الرابط ما كيوجهش لقائمة تشغيل صحيحة. عفاك تحقق من الرابط.",
"ERROR_401": "غير مصرح (401): المصادقة مطلوبة باش تدخل لهاد قائمة التشغيل."
},
"TEXT_IMPORT": {
"LABEL": "دخل قائمة تشغيل m3u(8) على شكل نص",
@@ -118,25 +160,68 @@
"STALKER_PORTAL": {
"TITLE": "العنوان",
"MAC_ADDRESS": "عنوان MAC",
"SERIAL_NUMBER": "الرقم التسلسلي (اختياري)",
"SERIAL_NUMBER_HINT": "استعملو إذا عندك تسجيل سابق مع المزود",
"DEVICE_ID_1": "معرف الجهاز 1 (اختياري)",
"DEVICE_ID_2": "معرف الجهاز 2 (اختياري)",
"DEVICE_ID_HINT": "64 حرف hex - استعملو إذا المزود كيتطلب معرف جهاز محدد",
"SIGNATURE_1": "التوقيع 1 (اختياري)",
"SIGNATURE_2": "التوقيع 2 (اختياري)",
"SIGNATURE_HINT": "64 حرف hex - استعملو إذا المزود كيتطلب توقيعات التحقق من الجهاز",
"SERVER_URL": "رابط الخادم",
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com/c ولا https://example.com/stalker_portal/c)",
"ADD": "زيد"
"ADD": "زيد",
"VALIDATING": "جاري التحقق من البوابة..."
},
"FILTER_BY_NAME": "فلتر بالاسم",
"FILTER_BY_TYPE": "فلتر بالنوع",
"FILTER_AND_SORT": "فلتر وترتيب",
"SORT_PLAYLISTS": "رتب قوائم التشغيل",
"SORT_BY": "رتب بـ",
"SORT_OPTIONS": {
"NAME_ASC": "الاسم (أ-ي)",
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)"
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (Drag & Drop)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "استورد كنص",
"PARSING_ERROR": "خطأ: التحليل فشل، ماشي قائمة تشغيل صحيحة:"
},
"SETTINGS": {
"TITLE": "الإعدادات",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "بدل تكوين التطبيق",
"PLAYBACK": "التشغيل",
"EPG_SOURCES": "مصادر EPG",
"EPG_SOURCES_DESCRIPTION": "زيد رابط ولا أكثر كمصادر EPG",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "فضّل مصادر EPG ديالك ل Xtream",
"PREFER_UPLOADED_EPG_HINT": "افتراضياً، قوائم Xtream كتستعمل دليل البرامج ديال المزود ديالك، ومصادر EPG ديالك كتعمر غير القنوات اللي ناقصة. فعّل هاد الخيار باش يتستعمل EPG ديالك الأول.",
"DATA": "البيانات",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"ABOUT": "حول",
"NAV_GENERAL": "عام",
"NAV_PLAYBACK": "التشغيل",
"NAV_EPG": "EPG",
"NAV_REMOTE": "التحكم عن بعد",
"NAV_DATA": "البيانات",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "حول",
"APP_PREFERENCES": "تفضيلات التطبيق",
"HERO_EYEBROW": "إعدادات مساحة العمل",
"HERO_TITLE": "شكّل كيفاش كيتحس IPTVnator",
"HERO_DESCRIPTION": "ضبط التشغيل، الأدلة، الوصول عن بعد، ومعالجة البيانات من بلاصة وحدة بدون ما تخسر ستايل مساحة العمل الحالية.",
"SUBTITLE": "بدل إعدادات التطبيق",
"DESCRIPTION": "عفاك بدل الخيارات من الليستا لتحت وأكد بضغط على زر حفظ التغييرات",
"EPG_URL_LABEL": "رابط EPG (*.xml ولا *.xml.gz)",
@@ -148,29 +233,72 @@
"EPG_STATUS_STALE": "خاصو يتحدث",
"EPG_STATUS_ERROR": "فشل",
"VIDEO_PLAYER_LABEL": "مشغل الفيديو",
"PLAYER_HTML5": "مشغل فيديو HTML5",
"PLAYER_VIDEOJS": "مشغل Video.js",
"PLAYER_ARTPLAYER": "ArtPlayer",
"PLAYER_EMBEDDED_MPV": "MPV مدمج (تجريبي، macOS)",
"PLAYER_MPV": "مشغل MPV",
"PLAYER_VLC": "VLC",
"VIDEO_PLAYER_PLACEHOLDER": "اختار خيار",
"VERSION": "الإصدار",
"SAVE_CHANGES": "حفظ التغييرات",
"BACK_TO_HOME": "رجوع",
"NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر",
"LATEST_VERSION": "راك تستعمل أحدث إصدار",
"STREAM_FORMAT": "صيغة البث",
"LANGUAGE": "اللغة",
"SETTINGS_SAVED": "نجاح! الإعدادات تحفضو.",
"THEME": "المظهر المرئي",
"SHOW_CAPTIONS": "وري الترجمة",
"SHOW_DASHBOARD": "وري لوحة التحكم",
"SHOW_DASHBOARD_DESCRIPTION": "خلي لوحة التحكم متاحة في شريط مساحة العمل. إلى تعطلت، Sources كيولي أول صفحة في مساحة العمل.",
"ADD_EPG_SOURCE": "زيد مصدر EPG",
"CLEAR_EPG_DATA": "مسح بيانات EPG",
"CLEAR_EPG_DIALOG": {
"TITLE": "مسح بيانات EPG",
"MESSAGE": "واش متأكد بغيتي تحيد جميع بيانات EPG (دليل البرامج الإلكتروني) من قاعدة البيانات؟ هاد العملية غادي تحيد جميع جداول البرامج المخزنة."
},
"EPG_DATA_CLEARED": "بيانات EPG تمسحات",
"EPG_DATA_CLEAR_FAILED": "فشل مسح بيانات EPG. عفاك حاول مرة أخرى.",
"CLEARING_EPG_DATA": "جاري المسح…",
"EPG_URL_ERROR": "القيمة ماشي رابط صحيح",
"VIDEO_PLAYER_DESCRIPTION": "اختار مشغل الفيديو الافتراضي",
"STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)",
"LANGUAGE_DESCRIPTION": "اختار لغة التطبيق",
"THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق",
"COVER_SIZE": "حجم الغلاف",
"COVER_SIZE_DESCRIPTION": "بدّل حجم بطايق الملصقات في الشبكات والقضبان. الأحجام الكبيرة سهال تشوفها على التلفزة؛ الأحجام الصغيرة كتوري كثر ديال المحتوى في كل صف على الشاشات الكبيرة.",
"COVER_SIZE_SMALL": "صغير",
"COVER_SIZE_MEDIUM": "متوسط",
"COVER_SIZE_LARGE": "كبير",
"STARTUP_BEHAVIOR": "العرض عند البدء",
"STARTUP_BEHAVIOR_DESCRIPTION": "اختار واش IPTVnator يحل أول عرض متوفر في مساحة العمل ولا يسترجع آخر عرض على مستوى القسم. أول عرض كيعني لوحة التحكم إلى كانت مفعلة، إلا فالا فهي Sources.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متوفر",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "استرجع آخر عرض",
"SHOW_CAPTIONS_DESCRIPTION": "وري ولا خفي الترجمة افتراضياً",
"SHOW_EXTERNAL_PLAYBACK_BAR": "وري شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "خلي شريط حالة المشغل الخارجي ظاهر في الأسفل ديال مساحة العمل على سطح المكتب.",
"VERSION_DESCRIPTION": "الإصدار الحالي ديال التطبيق",
"ABOUT_SUBTITLE": "الإصدار، روابط المشروع، وطرق دعم IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "وقت تشغيل MPV المدمج",
"EMBEDDED_MPV_NOTICE_DESCRIPTION": "إصدارات macOS تقدر تتضمن مكتبات libmpv و FFmpeg المدمجة لمشغل MPV المدمج التجريبي. المستخدمين ما محتاجينش يثبتو تطبيق MPV بشكل منفصل؛ MPV الخارجي و VLC مازالو متوفرين كمشغلات احتياطية.",
"COMMUNITY": "المشروع والمجتمع",
"COMMUNITY_DESCRIPTION": "حط نجمة على المستودع، قرا المدونة، ولا انضم لجروب Telegram للتحديثات والمساعدة.",
"LINK_BLOG": "المدونة",
"LINK_GITHUB_DESCRIPTION": "حط نجمة على المستودع، حل مشكل، ولا ساهم",
"LINK_BLOG_DESCRIPTION": "ملاحظات الإصدار، نصائح، وتحديثات المشروع",
"LINK_TELEGRAM_DESCRIPTION": "إعلانات، مجتمع، ودعم",
"SUPPORT_TITLE": "دعم المشروع",
"SUPPORT_DESCRIPTION": "IPTVnator مجاني ومفتوح المصدر، مبني بشكل مفتوح. إلى كان جزء من روتينك اليومي، مساهمة صغيرة كتعاون باش يبقى المشروع حي وتشكل اللي غادي يجي — شكرا.",
"SUPPORT_SPONSORS_HINT": "متكرر ولا مرة وحدة",
"SUPPORT_KOFI_HINT": "شري ليا قهوة",
"EPG_NOTE": "EPG متوفر حالياً غير في الإصدار اللي مبني على electron ديال IPTVnator.",
"EPG_NOTE_URL_TEXT": "شوف هنا",
"IMPORT_EXPORT_DATA": "استورد/صدّر قوائم التشغيل",
"IMPORT_EXPORT_DATA_DESCRIPTION": "صدّر ولا استورد قوائم التشغيل بصيغة JSON اللي كيستعملها التطبيق.",
"IMPORT_DATA": "استورد",
"EXPORT_DATA": "صدّر",
"EXPORTING_DATA": "جاري التصدير…",
"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.",
@@ -192,52 +320,123 @@
"MPV_PLAYER_PATH_LABEL": "مسار mpv",
"MPV_PLAYER_PATH_DESCRIPTION": "حدد مسار مشغل mpv في السيستيم ديالك. غادي يستعمل المسار اللي عطيتيه لملف mpv بدل اللي كيتلقى في $PATH.",
"MPV_PLAYER_PATH": "مسار مشغل mpv",
"MPV_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة MPV",
"MPV_REUSE_INSTANCE_DESCRIPTION": "إلى كانت مفعلة، نفس نافذة MPV غادي تستعمل لتشغيل بثوث مختلفة. إلى كانت معطلة، كل بث كيتحل في نافذة MPV جديدة.",
"VLC_PLAYER_PATH_LABEL": "مسار VLC",
"VLC_PLAYER_PATH_DESCRIPTION": "حدد مسار مشغل VLC في السيستيم ديالك. غادي يستعمل المسار اللي عطيتيه لملف VLC بدل المسار الافتراضي ديال السيستيم ديالنا.",
"VLC_PLAYER_PATH": "مسار مشغل VLC",
"VLC_REUSE_INSTANCE_LABEL": "إعادة استخدام نسخة VLC",
"VLC_REUSE_INSTANCE_DESCRIPTION": "إلى كانت مفعلة، نفس نافذة VLC غادي تستعمل لتشغيل بثوث مختلفة. إلى كانت معطلة، كل بث كيتحل في نافذة VLC جديدة.",
"REMOTE_CONTROL": "التحكم عن بعد",
"REMOTE_CONTROL_DESCRIPTION": "فعل التحكم عن بعد للتطبيق.",
"REMOTE_CONTROL_PORT": "منفذ التحكم عن بعد",
"REMOTE_CONTROL_PORT_DESCRIPTION": "المنفذ اللي كيتستعمل للتحكم عن بعد للتطبيق. المنفذ 3000 كيتستعمل افتراضياً.",
"REMOTE_CONTROL_URL": "رابط التحكم عن بعد",
"REMOTE_CONTROL_URL_DESCRIPTION": "وصل للتحكم عن بعد من جهاز آخر في نفس الشبكة.",
"SHOW_QR_CODE": "وري كود QR",
"STATUS_ENABLED": "مفعل",
"STATUS_DISABLED": "معطل",
"SOURCE_SINGULAR": "مصدر",
"SOURCE_PLURAL": "مصادر",
"PORT_RANGE_LABEL": "المنفذ (1-65535)",
"PORT_REQUIRED": "المنفذ مطلوب",
"PORT_MIN": "المنفذ خاصو يكون على الأقل 1",
"PORT_MAX": "المنفذ خاصو يكون على الأكثر 65535",
"PORT_PATTERN": "غير الأرقام مسموحة",
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset"
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "المظهر الداكن",
"LIGHT_THEME": "المظهر الفاتح",
"SYSTEM_THEME": "مظهر النظام"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "قلب على قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
"MANAGE_GROUPS": "تدبير المجموعات",
"MANAGE_GROUPS_HINT": "استعمل تدبير المجموعات باش توري المجموعات من جديد.",
"NO_VISIBLE_GROUPS": "ما كاينش مجموعات ظاهرة",
"SEARCH_GROUPS": "قلب على مجموعات",
"UNGROUPED": "غير مجمعة",
"UNNAMED_CHANNEL": "قناة بدون اسم",
"FAVORITES": "المفضلة",
"NO_FAVORITES": "مازال ما كاينش مفضلة",
"USE_STAR_TO_FAVORITE": "عفاك استعمل رمز 'النجمة' باش تزيد قنوات للمفضلة ديالك.",
"REMOVE_FAVORITE": "حيد من المفضلة",
"ADD_FAVORITE": "زيد للمفضلة",
"SHOW_DETAILS": "وري تفاصيل القناة",
"FAVORITES_UPDATED": "المفضلة تحدثات!",
"SELECT_CHANNEL_PLAYBACK": "عفاك اختار قناة باش يبدا التشغيل",
"SORT_BY": "رتب بـ",
"SORT_BY_DATE": "رتب بتاريخ الإضافة (الأحدث أولاً)",
"SORT_BY_RATING": "رتب بالتقييم",
"SORT_BY_NAME": "رتب بالاسم (أ-ي)"
"SORT_BY_NAME": "رتب بالاسم (أ-ي)",
"LOAD_MORE": "شارجي كثر",
"REMAINING": "باقي",
"LOADING": "جاري تشريج القنوات...",
"LOADING_MORE": "جاري تشريج المزيد...",
"NO_SEARCH_RESULTS": "ما تلقاتش حتى قناة",
"NO_CHANNELS_FOUND": "ما تلقاتش حتى قناة",
"NO_GROUPS_FOUND": "ما تلقاتش حتى مجموعة",
"TRY_DIFFERENT_SEARCH": "جرب كلمة بحث أخرى",
"DETAILS_DIALOG": {
"TITLE": "تفاصيل القناة",
"DESCRIPTION": "بيانات وصفية للقراءة فقط مستخرجة من ملف قائمة التشغيل.",
"SUMMARY": "ملخص",
"TVG": "بيانات TVG",
"CATCHUP": "Catchup",
"HTTP": "رؤوس HTTP",
"NAME": "الاسم",
"CHANNEL_ID": "ID ديال القناة",
"STREAM_URL": "رابط البث",
"GROUP": "المجموعة",
"RADIO": "راديو",
"CATCHUP_AVAILABLE": "Catchup متوفر",
"WINDOW": "نافذة الأرشيف",
"PLAYBACK_SUPPORTED": "التشغيل مدعوم",
"EPG_PARAMS": "بارامترات EPG",
"TIMESHIFT": "Timeshift",
"TVG_ID": "TVG ID",
"TVG_NAME": "TVG name",
"TVG_URL": "TVG URL",
"TVG_LOGO": "TVG logo",
"TVG_REC": "TVG rec",
"CATCHUP_TYPE": "نوع Catchup",
"CATCHUP_SOURCE": "مصدر Catchup",
"CATCHUP_DAYS": "أيام Catchup",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "ما متوفرش",
"NOT_AVAILABLE": "ما متوفرش",
"DAYS_ONE": "نهار واحد",
"DAYS_OTHER": "{{count}} ديال الأيام"
},
"GROUP_MANAGEMENT": {
"TITLE": "تدبير المجموعات",
"SELECTED": "متختار",
"SELECT_ALL": "اختار الكل",
"DESELECT_ALL": "حيد الاختيار من الكل",
"SEARCH_PLACEHOLDER": "قلب على مجموعات...",
"NO_RESULTS": "ما تلقاتش حتى مجموعة مطابقة",
"SAVE": "حفظ"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "حل قائمة القنوات",
"TOGGLE_FAVORITE_FLAG": "بدل علامة المفضلة",
"OPEN_EPG_LIST": "حل قائمة EPG",
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد"
"OPEN_MULTI_EPG": "حل عرض EPG المتعدد",
"SHOW_INFO": "وري معلومات البرنامج (ضغط على I)"
},
"EPG": {
"TITLE": "دليل البرامج الإلكتروني",
@@ -246,12 +445,16 @@
"LIVE_NOW": "مباشر الآن",
"LIVE_STREAM": "بث مباشر",
"TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "تشغيل الأرشيف ما متوفرش",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "هاد المزود كيوفر تاريخ جدول البرامج للقناة، ولكن ما كيوفرش تشغيل أرشيف/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "أوه، EPG ما متوفرش للتاريخ اللي اختاريتيه",
"NO_GUIDE_HINT": "جرب تاريخ آخر أو حدّث اللائحة.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "أوه، EPG ما متوفرش لهاد القناة.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "عفاك زيد/بدل رابط قائمة EPG في إعدادات التطبيق.",
"FETCH_EPG": "جاري جلب بيانات EPG...",
"ERROR": "أوه، EPG ما قدرش يتشارجا.",
"DOWNLOAD_SUCCESS": "EPG تشارجا بنجاح.",
"NO_PROGRAM_INFO": "ما كاين حتى معلومات على البرنامج",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "تفاصيل حول البرنامج التلفزيوني",
"TITLE": "العنوان",
@@ -259,9 +462,23 @@
"PARENTAL_RATING_SYSTEM": "نظام تقييم المحتوى التلفزيوني",
"DESCRIPTION": "الوصف",
"LANGUAGE": "اللغة",
"SHOW_PROGRAM_DETAILS": "وري تفاصيل حول هاد البرنامج"
"SHOW_PROGRAM_DETAILS": "وري تفاصيل حول هاد البرنامج",
"EPISODE": "الحلقة",
"DURATION": "المدة"
},
"FETCH_SUCCESS": "EPG تجلب بنجاح"
"FETCH_SUCCESS": "EPG تجلب بنجاح",
"IMPORT_PROGRESS": "استيراد EPG",
"CHANNELS": "قنوات",
"PROGRAMS": "برامج",
"LOADING": "جاري التشريج",
"QUEUED": "في الطابور",
"TOTAL_CHANNELS": "مجموع القنوات",
"TOTAL_PROGRAMS": "مجموع البرامج",
"UP_TO_DATE": "EPG محدث، ما خاصش مزامنة",
"RETRY": "حاول مرة أخرى",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -308,6 +525,7 @@
"REFRESH": "حدث",
"XTREAM": {
"PLAY": "لعب",
"OPENING_PLAYER": "جاري حل المشغل...",
"NAME": "الاسم",
"RELEASE_DATE": "تاريخ الإصدار",
"GENRE": "النوع",
@@ -324,7 +542,59 @@
"YEAR": "السنة",
"AGE": "العمر",
"YOUTUBE_TRAILER": "مقطع دعائي على يوتيوب",
"EPISODE": "الحلقة"
"EPISODE": "الحلقة",
"RESUME": "كمل",
"RESTART": "بدا من الأول",
"CONTINUE_WATCHING": "كمل المشاهدة",
"WATCHED": "متشاف",
"IN_PROGRESS": "جاري",
"MARK_WATCHED": "علم كمتشاف",
"MARK_UNWATCHED": "علم كما متشافش",
"EPISODES_WATCHED": "{{count}} من {{total}} ديال الحلقات متشافين",
"DETAIL_FALLBACK": {
"STATUS": "البيانات الوصفية ديال البوابة ما متوفراش",
"NOTE": "البيانات الوصفية الموسعة ما توفراتش من هاد البوابة.",
"STREAM_ID": "Stream ID",
"CATEGORY": "الفئة",
"CONTAINER": "الصيغة",
"SOURCE_TITLE": "عنوان المصدر"
},
"ACCOUNT_INFO": {
"TITLE": "معلومات الحساب",
"USER_INFO": "معلومات المستخدم",
"STATUS": "الحالة",
"USERNAME": "اسم المستخدم",
"ACTIVE_CONNECTIONS": "الاتصالات النشطة",
"CREATED": "تنشأ",
"EXPIRES": "كينتهي",
"TRIAL_ACCOUNT": "حساب تجريبي",
"YES": "آه",
"NO": "لا",
"ALLOWED_FORMATS": "الصيغ المسموحة",
"SERVER_INFO": "معلومات السيرفر",
"LIVE_TV": "البث المباشر",
"MOVIES": "الأفلام",
"TV_SERIES": "السلاسل",
"URL": "URL",
"PROTOCOL": "البروتوكول",
"TIMEZONE": "المنطقة الزمنية",
"SERVER_TIME": "وقت السيرفر",
"PORTS": "المنافذ",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
},
"CATEGORY_MANAGEMENT": {
"TITLE": "تدبير الفئات",
"LOADING": "جاري تشريج الفئات...",
"SELECTED": "متختار",
"SELECT_ALL": "اختار الكل",
"DESELECT_ALL": "حيد الاختيار من الكل",
"SEARCH_PLACEHOLDER": "قلب على فئات...",
"NO_RESULTS": "ما تلقاتش حتى فئة مطابقة",
"NO_CATEGORIES": "ما كاينش فئات متوفرة",
"SAVE": "حفظ"
}
},
"PORTALS": {
"ALL_CATEGORIES": "جميع الفئات",
@@ -333,11 +603,47 @@
"REMOVE_FROM_FAVORITES": "حيد من المفضلة",
"REMOVED_FROM_FAVORITES": "تحيد من المفضلة",
"ADDED_TO_FAVORITES": "تزاد للمفضلة",
"FAVORITES": "المفضلة",
"RECENTLY_VIEWED": "متشافين مؤخراً",
"THIS_PLAYLIST": "هاد قائمة التشغيل",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"LIVE_TV": "البث المباشر",
"MOVIES": "الأفلام",
"SERIES": "السلاسل",
"SEARCH": "بحث",
"NO_FAVORITES": "مازال ما كاينش مفضلة",
"NO_RECENT_ITEMS": "ما كاينش عناصر متشافة مؤخراً",
"REMOVE_FROM_RECENT": "حيد من المتشاف مؤخراً",
"BROWSE_ALL_MOVIES": "تصفح جميع الأفلام",
"BROWSE_ALL_SERIES": "تصفح جميع السلاسل",
"BROWSE_ALL_LIVE": "تصفح جميع البث المباشر",
"SIDEBAR": {
"MAIN": "الرئيسي",
"MOVIES": "الأفلام",
"LIVE_TV": "البث المباشر",
"SERIES": "السلاسل",
"LIBRARY": "المكتبة",
"SEARCH": "بحث",
"RECENT": "أخير",
"RECENTLY_ADDED": "تزادو مؤخراً",
"FAVORITES": "المفضلة",
"HOME": "الرئيسية",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
"TITLE": "الاستيراد تلغى",
"DESCRIPTION": "استيراد قائمة التشغيل تلغى. حاول مرة أخرى منين تبغي تكمل تشريج هاد المصدر."
},
"ACCOUNT_EXPIRED": {
"TITLE": "الحساب انتهى",
"DESCRIPTION": "هاد الحساب انتهت صلاحيته. عفاك تحقق من بيانات الاعتماد في إعدادات قائمة التشغيل ولا حيدها."
},
"ACCOUNT_INACTIVE": {
"TITLE": "الحساب غير نشط",
"DESCRIPTION": "هاد الحساب غير نشط. عفاك تحقق من بيانات الاعتماد في إعدادات قائمة التشغيل ولا حيدها."
},
"UNAUTHORIZED": {
"TITLE": "غير مصرح",
"DESCRIPTION": "عفاك تحقق من بيانات الاعتماد في إعدادات قائمة التشغيل ولا حيدها."
@@ -346,6 +652,10 @@
"TITLE": "البوابة ما تلقاتش",
"DESCRIPTION": "عفاك تحقق من بيانات الاعتماد ورابط البوابة في إعدادات قائمة التشغيل ولا حيدها."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "البوابة ما متوفراش",
"DESCRIPTION": "البوابة ما تلقاتش. تحقق من رابط السيرفر ولا حاول مرة أخرى منين يولي المصدر متاح."
},
"EMPTY_CATEGORY": {
"TITLE": "فئة فارغة",
"DESCRIPTION": "هاد الفئة ما فيهاش محتوى، عفاك اختار فئة أخرى ولا بدل نوع المحتوى."
@@ -354,30 +664,173 @@
"TITLE": "وقع شي خطأ",
"DESCRIPTION": "خطأ غير معروف، عفاك حاول مرة أخرى من بعد."
},
"NO_CATEGORY_SELECTED": {
"TITLE": "ما كاين حتى فئة متختارة",
"DESCRIPTION": "عفاك اختار فئة باش تشوف المحتوى"
},
"PLAYLIST_SETTINGS": "إعدادات قائمة التشغيل",
"HOME": "الرئيسية",
"DELETE": "حذف"
},
"EMPTY_LIST_VIEW": {
"TITLE": "ما تلقاتش نتائج",
"NO_SEARCH_RESULTS": "ما تلقى والو، حاول تبدل طلب البحث ديالك"
},
"SEARCH_VIEW": {
"INITIAL_TITLE": "قلب على محتوى",
"INITIAL_DESCRIPTION": "دخل على الأقل 3 حروف باش تقلب في البث المباشر، الأفلام والسلاسل.",
"RESULTS_FOUND": "تلقاو {{count}} ديال النتايج",
"NO_RESULTS_FOR": "ما تلقاو حتى نتيجة لـ \"{{term}}\"",
"GROUP_BY_PLAYLIST": "جمع النتائج حسب قائمة التشغيل",
"XTREAM_ONLY_NOTE": "البحث الشامل متوفر دابا غير لقوائم تشغيل Xtream.",
"VISIBLE_CATEGORIES_ONLY": "غير الفئات الظاهرة"
},
"STREAM_URL_COPIED": "رابط البث تنقل للحافظة",
"COPY_STREAM_URL": "نسخ رابط البث",
"SEARCH_VIEW": {
"RESULTS_FOUND": "تلقاو {{count}} ديال النتايج",
"NO_RESULTS_FOR": "ما تلقاو حتى نتيجة لـ \"{{term}}\""
}
"SELECT_CATEGORY": "اختار فئة",
"PAGE": "الصفحة",
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
"PLAYBACK_ERROR": "فشل جلب رابط التشغيل",
"LOADING_SEASONS": "جاري تشريج المواسم...",
"NO_EPISODES": "ما كاينش حلقات متوفرة",
"ALL_RADIO": "All radio"
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
"TITLE": "التحميلات",
"DOWNLOAD": "تحميل",
"PLAY_LOCAL": "شغل محلياً",
"EMPTY": "مازال ما كاينش تحميلات",
"EMPTY_HINT": "حمل أفلام وحلقات باش تشاف بدون أنترنت",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term",
"NOT_AVAILABLE": "التحميلات ما متوفراش",
"DESKTOP_ONLY": "هاد الميزة متوفرة غير في تطبيق سطح المكتب",
"NO_FOLDER": "ما كاين حتى مجلد تحميل متختار",
"CHANGE_FOLDER": "بدل المجلد",
"CLEAR_COMPLETED": "مسح المكتملين",
"CLEAR_COMPLETED_DIALOG": {
"TITLE": "مسح التحميلات المكتملة",
"MESSAGE": "هاد العملية كتحيد التحميلات المكتملة، الفاشلة، والملغاة من عرض التحميلات الحالي. ما كتحيدش الملفات اللي تحملو من قبل على القرص."
},
"SOURCE_PLAYLIST_MISSING": "هاد التحميل ما يقدرش يتحل في المكتبة حيت قائمة التشغيل المصدر تحيدات.",
"FILE_NOT_FOUND": "هاد الملف المحمل ما بقاش متوفر على القرص.",
"FILE_ACTION_ERROR": "العملية على الملف ما تكملاتش.",
"CANCEL": "إلغاء",
"RETRY": "حاول مرة أخرى",
"REMOVE": "حيد",
"PLAY": "شغل",
"REVEAL": "وري في المجلد",
"COPY_URL": "نسخ رابط التحميل",
"URL_COPIED": "رابط التحميل تنسخ للحافظة",
"URL_COPY_FAILED": "فشل نسخ الرابط للحافظة",
"STATUS": {
"QUEUED": "في الطابور",
"DOWNLOADING": "جاري التحميل",
"COMPLETED": "كمل",
"FAILED": "فشل",
"CANCELED": "تلغى"
}
},
"REMOTE_CONTROL": {
"TITLE": "التحكم عن بعد",
"SUBTITLE": "تحكم في مشغل IPTV ديالك عن بعد",
"UP_CHANNEL": "القناة الفوق",
"DOWN_CHANNEL": "القناة لتحت",
"HEADER": "التحكم عن بعد IPTVNator",
"FOOTER": "التحكم عن بعد IPTVNator"
},
"WORKSPACE": {
"FAVORITES_ONLY": "المفضلة فقط",
"SORT_CUSTOM": "ترتيب مخصص",
"SORT_DATE_DESC": "تاريخ الإضافة (الأحدث أولاً)",
"SORT_DATE_ASC": "تاريخ الإضافة (الأقدم أولاً)",
"SORT_NAME_ASC": "الاسم أ-ي",
"SORT_NAME_DESC": "الاسم ي-أ",
"SORT_LABEL": "ترتيب: ",
"DASHBOARD": {
"CONTINUE_WATCHING": "كمل المشاهدة",
"UNTITLED_SOURCE": "مصدر بلا عنوان",
"GLOBAL_FAVORITES": "المفضلة العامة",
"REMOVE_FROM_HISTORY": "حيد من السجل",
"RECENT_SOURCES": "المصادر المستعملة مؤخراً",
"MANAGE_ALL": "تدبير الكل",
"RECENTLY_WATCHED": "متشافين مؤخراً",
"XTREAM_RECENTLY_ADDED": "تزادو مؤخراً على Xtream",
"NO_HISTORY": "ما تلقاو حتى عناصر لهاد الفلتر.",
"NO_HISTORY_HINT": "سجل المشاهدات ديالك غادي يبان هنا منين تبدا تشغل محتوى من قوائم التشغيل ديالك.",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U",
"STALKER_PORTAL": "Stalker Portal",
"PROVIDER": "المزود",
"TYPE_LIVE": "مباشر",
"TYPE_MOVIE": "فيلم",
"TYPE_SERIES": "سلسلة",
"NOT_YET_SYNCED": "مازال ما تزامنش"
},
"SHELL": {
"BRAND_ALT": "IPTVnator",
"RAIL_DASHBOARD": "لوحة التحكم",
"OPEN_DASHBOARD": "حل لوحة التحكم",
"RAIL_SOURCES": "المصادر",
"OPEN_SOURCES": "حل المصادر",
"RAIL_GLOBAL_FAVORITES": "المفضلة",
"RAIL_GLOBAL_RECENT": "متشافين مؤخراً",
"RAIL_SETTINGS": "الإعدادات",
"OPEN_SETTINGS": "حل الإعدادات",
"RAIL_LIBRARY": "المكتبة",
"RAIL_MOVIES": "الأفلام",
"RAIL_LIVE": "البث المباشر",
"RAIL_SERIES": "السلاسل",
"RAIL_RECENTLY_ADDED": "تزادو مؤخراً",
"RAIL_SEARCH": "بحث متقدم",
"RAIL_RECENT": "متشافين مؤخراً",
"RAIL_FAVORITES": "المفضلة",
"RAIL_DOWNLOADS": "التحميلات",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"RAIL_ALL_CHANNELS": "جميع القنوات",
"RAIL_GROUPS": "المجموعات",
"RAIL_CONTEXT_ACTIONS": "إجراءات القسم",
"SEARCH_LABEL": "بحث",
"SEARCH_SOURCES_PLACEHOLDER": "قلب على مصادر (جميع قوائم التشغيل)...",
"SEARCH_PLAYLIST_PLACEHOLDER": "قلب في هاد قائمة التشغيل...",
"SEARCH_SECTION_PLACEHOLDER": "قلب في هاد القسم...",
"FILTER_SECTION_PLACEHOLDER": "فلتر هاد القسم...",
"SEARCH_STATUS_LOADED_ONLY": "غير القنوات المشرّجة",
"COMMAND_PALETTE_SHORTCUT": "لوحة الأوامر (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "حل لوحة الأوامر",
"ADD_PLAYLIST_ALL": "زيد قائمة تشغيل",
"ADD_PLAYLIST": "زيد قائمة تشغيل",
"OPEN_DOWNLOADS": "حل التحميلات",
"OPEN_CONTEXT_ACTIONS": "حل إجراءات القسم",
"NO_SECTION_ACTIONS": "ما كاين حتى إجراءات لهاد القسم",
"PLAYLIST_INFO": "معلومات قائمة التشغيل",
"ACCOUNT_INFO": "معلومات الحساب",
"REFRESH_PLAYLIST": "حدث قائمة التشغيل",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "مسح التحميلات المكتملة (جميع قوائم التشغيل)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "مسح التحميلات المكتملة لجميع قوائم التشغيل",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "مسح التحميلات المكتملة (هاد قائمة التشغيل)",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "مسح التحميلات المكتملة لهاد قائمة التشغيل",
"CLEAR_RECENTLY_VIEWED_SECTION": "مسح المتشافين مؤخراً",
"CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "مسح المتشافين مؤخراً لهاد القسم",
"CLEAR_RECENTLY_VIEWED_TYPE": "مسح {{type}} المتشافين مؤخراً",
"CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "مسح {{type}} المتشافين مؤخراً؟",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "جميع عناصر {{type}} من هاد قائمة التشغيل غادي يتحيدو من سجل المتشافين مؤخراً ديالك. هاد العملية ما تقدرش ترجع.",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL": "جميع عناصر {{type}} في جميع قوائم التشغيل غادي يتحيدو من سجل المتشافين مؤخراً ديالك. هاد العملية ما تقدرش ترجع.",
"CLEAR_FAVORITES_TYPE": "مسح مفضلة {{type}}",
"CLEAR_FAVORITES_DIALOG_TITLE": "حيد جميع مفضلة {{type}}؟",
"CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST": "جميع مفضلة {{type}} من هاد قائمة التشغيل غادي يتحيدو. هاد العملية ما تقدرش ترجع.",
"CLEAR_FAVORITES_DIALOG_MESSAGE_ALL": "جميع مفضلة {{type}} في جميع قوائم التشغيل غادي يتحيدو. هاد العملية ما تقدرش ترجع.",
"XTREAM_CODE": "Xtream Code",
"STALKER_PORTAL": "Stalker Portal",
"CHANNELS_COUNT": "{{count}} قناة",
"NO_SOURCES_AVAILABLE": "ما كاينش مصادر متوفرة",
"ONE_SOURCE_AVAILABLE": "مصدر واحد متوفر",
"SOURCES_AVAILABLE": "{{count}} ديال المصادر متوفرة",
"GO_TO_DASHBOARD": "Back to dashboard",
"GO_TO_SOURCES": "Go to Sources",
"DROP_OVERLAY_TITLE": "Drop to add playlist",
@@ -387,21 +840,129 @@
"DROP_OVERLAY_REJECTED_EMPTY": "That file is empty",
"DROP_OVERLAY_REJECTED_READ_ERROR": "Could not read that file",
"DROP_IMPORT_SUCCESS": "Importing \"{{title}}\"…",
"XTREAM_IMPORT_TITLE": "جاري مزامنة قائمة التشغيل",
"XTREAM_IMPORT_REMOTE_BADGE": "مصدر بعيد",
"XTREAM_IMPORT_LOCAL_BADGE": "المكتبة المحلية",
"XTREAM_IMPORT_DETAIL_REMOTE": "كنتسناو رد المزود.",
"XTREAM_IMPORT_DETAIL_LOCAL": "كنوجدو تبديل أسرع لقوائم التشغيل للزيارة الجاية.",
"XTREAM_IMPORT_PREPARING": "جاري تحضير المكتبة المحلية...",
"XTREAM_IMPORT_LOADING": "جاري جلب بيانات قائمة التشغيل من المصدر...",
"XTREAM_IMPORT_SAVING": "جاري حفظ بيانات قائمة التشغيل محلياً...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "جاري استرجاع المفضلة من المكتبة المحلية...",
"XTREAM_IMPORT_RESTORING_RECENT": "جاري استرجاع المتشافين مؤخراً من المكتبة المحلية...",
"XTREAM_IMPORT_PROGRESS": "{{type}} مستوردة: {{current}} / {{total}}",
"XTREAM_IMPORT_CANCEL": "وقف المزامنة",
"XTREAM_IMPORT_CANCELLING": "جاري وقف المزامنة...",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "قلب في جميع قوائم تشغيل Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "حل واجهة البحث الشامل",
"PLAYLIST_SEARCH_LABEL": "بحث متقدم في هاد قائمة التشغيل",
"PLAYLIST_SEARCH_DESCRIPTION": "حل البحث المتقدم لقائمة التشغيل",
"OPEN_GLOBAL_FAVORITES_LABEL": "حل المفضلة العامة",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "وجه للمفضلة المجمعة",
"OPEN_DOWNLOADS_LABEL": "حل التحميلات",
"OPEN_DOWNLOADS_DESCRIPTION": "وجه لعرض التحميلات",
"OPEN_GLOBAL_RECENT_LABEL": "حل المتشافين مؤخراً",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "حل صفحة المتشافين مؤخراً العامة",
"OPEN_SETTINGS_LABEL": "حل الإعدادات",
"OPEN_SETTINGS_DESCRIPTION": "وجه لإعدادات التطبيق",
"OPEN_SOURCES_LABEL": "تدبير المصادر",
"OPEN_SOURCES_DESCRIPTION": "وجه لتدبير المصادر",
"OPEN_DASHBOARD_LABEL": "حل لوحة التحكم",
"OPEN_DASHBOARD_DESCRIPTION": "وجه للوحة التحكم",
"ADD_PLAYLIST_LABEL": "زيد مصدر جديد",
"ADD_PLAYLIST_DESCRIPTION": "حل واجهة زيادة قائمة تشغيل",
"ADD_PLAYLIST_M3U_LABEL": "Add M3U playlist",
"ADD_PLAYLIST_M3U_DESCRIPTION": "Open add dialog on the M3U file or URL tab",
"ADD_PLAYLIST_XTREAM_LABEL": "Add Xtream Codes playlist",
"ADD_PLAYLIST_XTREAM_DESCRIPTION": "Open add dialog on the Xtream Codes tab",
"ADD_PLAYLIST_STALKER_LABEL": "Add Stalker portal",
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab"
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab",
"REFRESH_PLAYLIST_LABEL": "حدث قائمة التشغيل",
"REFRESH_PLAYLIST_DESCRIPTION": "أعد مزامنة البيانات من المصدر البعيد",
"PLAYLIST_INFO_LABEL": "تفاصيل قائمة التشغيل",
"PLAYLIST_INFO_DESCRIPTION": "وري معلومات قائمة التشغيل الحالية",
"ACCOUNT_INFO_LABEL": "معلومات الحساب",
"ACCOUNT_INFO_DESCRIPTION": "وري تفاصيل حساب سيرفر Xtream",
"OPEN_VIEW_DESCRIPTION": "وجه ل{{view}}",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "مسح جميع عناصر {{type}} الظاهرة في هاد العرض",
"OPEN_MULTI_EPG_DESCRIPTION": "حل عرض EPG متعدد القنوات",
"GO_TO_VOD_LABEL": "الأفلام",
"GO_TO_VOD_DESCRIPTION": "اقفز لقسم الأفلام",
"GO_TO_LIVE_LABEL": "البث المباشر",
"GO_TO_LIVE_DESCRIPTION": "اقفز لقسم البث المباشر",
"GO_TO_SERIES_LABEL": "السلاسل",
"GO_TO_SERIES_DESCRIPTION": "اقفز لقسم السلاسل",
"SWITCH_PLAYER_LABEL": "بدل المشغل ل{{name}}",
"SWITCH_PLAYER_DESCRIPTION": "حدد {{name}} كمشغل الفيديو النشط",
"SWITCH_PLAYER_FEEDBACK": "المشغل تحدد ل{{name}}"
},
"EXTERNAL_PLAYBACK_CLOSE": "Close player"
"XTREAM_REFRESH_TITLE": "كنحدّثو البلايليست",
"XTREAM_REFRESH_COLLECTING_DATA": "كنحافظو على معطيات المكتبة ديالك...",
"XTREAM_REFRESH_DELETING_CONTENT": "كنحيدو الستريمات المخزنة...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "كنحيدو التصنيفات المخزنة...",
"XTREAM_REFRESH_DETAIL_LOCAL": "كنخليو المفضلات، تاريخ المشاهدة، التصنيفات المخفية، وتقدم التشغيل واجدين باش يرجعو من بعد المزامنة.",
"XTREAM_REFRESH_PROGRESS": "السجلات المحلية اللي تجهزات: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "المصادر",
"SUBTITLE": "فلتر ورتب قوائم التشغيل",
"TYPE": "النوع",
"ALL": "الكل",
"M3U_PLAYLISTS": "قوائم تشغيل M3U",
"XTREAM_PLAYLISTS": "قوائم تشغيل Xtream",
"STALKER_PLAYLISTS": "قوائم تشغيل Stalker",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
"PLAYLIST_COUNT_ONE": "قائمة تشغيل وحدة",
"PLAYLIST_COUNT_OTHER": "{{count}} ديال قوائم التشغيل"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
"MOVIE_CATEGORIES": "فئات الأفلام",
"SERIES_CATEGORIES": "فئات السلاسل",
"LIVE_CATEGORIES": "فئات البث المباشر",
"XTREAM_SYNCING_MOVIES": "جاري مزامنة فئات الأفلام...",
"XTREAM_SYNCING_SERIES": "جاري مزامنة فئات السلاسل...",
"XTREAM_SYNCING_LIVE": "جاري مزامنة فئات البث المباشر...",
"XTREAM_SOURCE": "مصدر Xtream",
"STALKER_PORTAL": "Stalker portal",
"MANAGE_CATEGORIES": "تدبير الفئات",
"LOAD_CATEGORIES_ERROR": "ما قدرناش نشرجو الفئات",
"LOAD_CATEGORIES_ERROR_HINT": "عفاك حاول مرة أخرى ولا تحقق من إعدادات البوابة.",
"FILTER_BY_TYPE": "فلتر بالنوع",
"ITEM_COUNT_ONE": "عنصر واحد",
"ITEM_COUNT_OTHER": "{{count}} ديال العناصر",
"SEARCH": "بحث",
"SEARCH_CATEGORIES": "قلب على فئات",
"RADIO_CATEGORIES": "Radio Categories"
},
"COMMAND_PALETTE": {
"TITLE": "لوحة الأوامر",
"SUBTITLE": "إجراءات عامة وسياقية",
"SEARCH_PLACEHOLDER": "كتب باش تفلتر الأوامر",
"SEARCH_LABEL": "فلتر الأوامر",
"EMPTY": "ما تلقاتش حتى أمر",
"GROUP_GLOBAL": "إجراءات عامة",
"GROUP_PLAYLIST": "هاد قائمة التشغيل",
"GROUP_VIEW": "العرض الحالي",
"GROUP_SECTION": "هاد القسم",
"GROUP_RECENT": "مستعملة مؤخراً"
},
"GLOBAL_FAVORITES": {
"TITLE": "المفضلة العامة",
"SUBTITLE": "البث المباشر من جميع قوائم التشغيل",
"SEARCH_PLACEHOLDER": "قلب على المفضلة...",
"NO_RESULTS": "ما كاين حتى نتيجة",
"NO_RESULTS_HINT": "جرب كلمة بحث أخرى",
"EMPTY_TITLE": "مازال ما كاينش مفضلة بث مباشر",
"EMPTY_HINT": "حط نجمة على القنوات المباشرة من قوائم تشغيل M3U، Xtream، ولا Stalker باش تشوفها هنا.",
"EMPTY_PLAYER": "اختار قناة باش تبدا المشاهدة",
"NO_PLAYLISTS_TITLE": "No favorites to show",
"NO_PLAYLISTS_BODY": "Add a playlist to start collecting favorites from live channels, movies, and series.",
"NO_ITEMS_TITLE": "No favorites yet",
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here."
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here.",
"UNKNOWN_CHANNEL": "غير معروف"
},
"GLOBAL_RECENT": {
"NO_PLAYLISTS_TITLE": "Nothing watched yet",
@@ -409,12 +970,5 @@
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
}
},
"DOWNLOADS": {
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term"
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+482 -59
View File
@@ -1,5 +1,9 @@
{
"CANCEL": "Ακύρωση",
"CLEAR": "Εκκαθάριση",
"CLOSE": "Κλείσιμο",
"SHOW_MORE": "Περισσότερα",
"SHOW_LESS": "Λιγότερα",
"PLAYLISTS": "Λίστα Αναπαραγωγής",
"HOME": {
"TITLE": "IPTVnator",
@@ -21,6 +25,36 @@
"FEATURE_M3U": "M3U/M3U8 Αρχεία",
"FEATURE_XTREAM": "Xtream Codes",
"FEATURE_STALKER": "Stalker Portal",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"NO_RESULTS_TITLE": "Δεν βρέθηκε λίστα αναπαραγωγής",
"NO_RESULTS_DESCRIPTION": "Δεν υπάρχουν λίστες αναπαραγωγής που να αντιστοιχούν στην αναζήτησή σας. Δοκιμάστε να προσαρμόσετε τους όρους αναζήτησης ή να καταργήσετε το φίλτρο για να δείτε όλες τις λίστες αναπαραγωγής.",
"ADDED_VIA_URL": "Προστέθηκε από ανέβασμα διεύθυνσης URL. URL:",
@@ -60,16 +94,28 @@
"REMOVE_DIALOG": {
"TITLE": "Διαγραφή λίστας αναπαραγωγής",
"MESSAGE": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτήν τη λίστα αναπαραγωγής;",
"SUCCESS": "Η λίστα αναπαραγωγής αφαιρέθηκε με επιτυχία."
"SUCCESS": "Η λίστα αναπαραγωγής αφαιρέθηκε με επιτυχία.",
"IN_PROGRESS": "Διαγραφή λίστας αναπαραγωγής...",
"DELETING_FAVORITES": "Διαγραφή αγαπημένων...",
"DELETING_RECENT": "Διαγραφή πρόσφατα προβληθέντων...",
"DELETING_PROGRESS": "Διαγραφή προόδου αναπαραγωγής...",
"DELETING_DOWNLOADS": "Διαγραφή λήψεων...",
"DELETING_CONTENT": "Διαγραφή προσωρινά αποθηκευμένου περιεχομένου...",
"DELETING_CATEGORIES": "Διαγραφή κατηγοριών...",
"DELETING_PLAYLIST": "Διαγραφή εγγραφής λίστας αναπαραγωγής..."
},
"UPDATED": "Ενημερώθηκε",
"REFRESH": "Ανανέωση λίστας αναπαραγωγής",
"REFRESH_XTREAM": "Ανανέωση Xtream λίστας από το τηλεκοντρόλ",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Ανανέωση Xtream λίστας",
"MESSAGE": "Αυτό θα διαγράψει τις τοπικά αποθηκευμένες κατηγορίες και ροές και θα εισαγάγει ξανά τα πάντα από τον απομακρυσμένο διακομιστή. Τα αγαπημένα σας και τα πρόσφατα προβληθέντα στοιχεία θα διατηρηθούν. Είστε βέβαιοι ότι θέλετε να συνεχίσετε;",
"MESSAGE": "Αυτό θα διαγράψει την τοπική προσωρινή μνήμη Xtream και θα εισαγάγει ξανά τις κατηγορίες και τις ροές από τον απομακρυσμένο διακομιστή. Τα αγαπημένα, τα πρόσφατα προβληθέντα στοιχεία, η ορατότητα των κρυφών κατηγοριών και η πρόοδος αναπαραγωγής θα αποκατασταθούν μετά από επιτυχημένη επανεισαγωγή, εφόσον το αντίστοιχο περιεχόμενο εξακολουθεί να υπάρχει. Αν ο διακομιστής δεν είναι προσβάσιμος, η αποκατάσταση θα παραμείνει σε εκκρεμότητα μέχρι μια μεταγενέστερη επιτυχημένη ανανέωση. Συνέχεια;",
"STARTED": "Η ανανέωση ξεκίνησε. Πλοήγηση στη λίστα αναπαραγωγής...",
"ERROR": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Δοκιμάστε ξανά."
"ERROR": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Δοκιμάστε ξανά.",
"IN_PROGRESS": "Ανανέωση λίστας αναπαραγωγής...",
"COLLECTING_DATA": "Συλλογή αγαπημένων και πρόσφατα προβληθέντων...",
"DELETING_CONTENT": "Διαγραφή προσωρινά αποθηκευμένου περιεχομένου...",
"DELETING_CATEGORIES": "Διαγραφή προσωρινά αποθηκευμένων κατηγοριών..."
},
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
"GLOBAL_FAVORITES_DESCRIPTION": "Αυτόματα δημιουργημένη λίστα αναπαραγωγής με συλλογή αγαπημένων από όλες τις λίστες αναπαραγωγής",
@@ -77,49 +123,26 @@
"MY_PLAYLISTS_SUBTITLE": "όλες οι διαθέσιμες λίστες αναπαραγωγής",
"PLAYLIST_UPDATE_SUCCESS": "Επιτυχία! Η λίστα αναπαραγωγής ενημερώθηκε με επιτυχία.",
"PLAYLIST_UPDATE_ERROR": "Σφάλμα κατά την ενημέρωση των λεπτομερειών της λίστας αναπαραγωγής.",
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Το τοπικό αρχείο δεν είναι πλέον διαθέσιμο. Ελέγξτε τη διαδρομή του αρχείου ή εισαγάγετε ξανά τη λίστα αναπαραγωγής.",
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Η εφαρμογή δεν έχει πλέον πρόσβαση στο τοπικό αρχείο.",
"AUTO_REFRESH_UPDATE_SUCCESS": "Επιτυχία! Οι λίστες αναπαραγωγής ενημερώθηκαν με επιτυχία",
"AUTO_REFRESH_ENABLED": "Η αυτόματη ανανέωση είναι ενεργοποιημένη",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen."
"AUTO_REFRESH_ENABLED": "Η αυτόματη ανανέωση είναι ενεργοποιημένη"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Σύρετε και αποθέστε αρχεία εδώ",
"OR": "or",
"CHOOSE_PLAYLIST": "επιλέξτε μια λίστα αναπαραγωγής",
"BROWSE": "Περιήγηση αρχείων",
"REJECTED": "Το αρχείο απορρίφθηκε, η μορφή αρχείου δεν υποστηρίζεται {{filename}}."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Διεύθηνση URL λίστας αναπαραγωγής (m3u, m3u8)",
"ADD_PLAYLIST": "Προσθήκη λίστας αναπαραγωγής",
"CORS_NOTE": "Σημείωση: Για την αποφυγή προβλημάτων CORS σε αυτό το σημείο, η εφαρμογή χρησιμοποιεί μια δημόσια υπηρεσία CORS Proxy. Εάν θέλετε να ανεβάσετε μια λίστα αναπαραγωγής με ευαίσθητα δεδομένα, είναι πιο ασφαλές να την εισαγάγετε από ένα αρχείο."
"CORS_NOTE": "Σημείωση: Για την αποφυγή προβλημάτων CORS σε αυτό το σημείο, η εφαρμογή χρησιμοποιεί μια δημόσια υπηρεσία CORS Proxy. Εάν θέλετε να ανεβάσετε μια λίστα αναπαραγωγής με ευαίσθητα δεδομένα, είναι πιο ασφαλές να την εισαγάγετε από ένα αρχείο.",
"ERROR_FETCH_FAILED": "Αποτυχία λήψης της λίστας αναπαραγωγής. Ελέγξτε τη διεύθυνση URL και δοκιμάστε ξανά.",
"ERROR_403": "Άρνηση πρόσβασης (403): Ο διακομιστής απέρριψε το αίτημα. Η διεύθυνση URL μπορεί να απαιτεί έλεγχο ταυτότητας ή η λίστα αναπαραγωγής είναι περιορισμένη.",
"ERROR_404": "Η λίστα αναπαραγωγής δεν βρέθηκε (404): Η διεύθυνση URL δεν δείχνει σε έγκυρη λίστα αναπαραγωγής. Ελέγξτε τη διεύθυνση URL.",
"ERROR_401": "Μη εξουσιοδοτημένος (401): Απαιτείται έλεγχος ταυτότητας για πρόσβαση σε αυτή τη λίστα αναπαραγωγής."
},
"TEXT_IMPORT": {
"LABEL": "Προσθήκη m3u(8) λίστας αναπαραγωγής ως κείμενο",
@@ -159,7 +182,8 @@
"NAME_ASC": "Όνομα (A-Z)",
"NAME_DESC": "Όνομα (Z-A)",
"NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)",
"OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)"
"OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)",
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (Drag & Drop)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)",
@@ -171,6 +195,33 @@
},
"SETTINGS": {
"TITLE": "Ρυθμίσεις",
"GENERAL": "Γενικά",
"GENERAL_SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής",
"PLAYBACK": "Αναπαραγωγή",
"EPG_SOURCES": "Πηγές EPG",
"EPG_SOURCES_DESCRIPTION": "Προσθέστε μία ή περισσότερες διευθύνσεις URL ως πηγές EPG",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Προτίμηση των δικών σου πηγών EPG για λίστες Xtream",
"PREFER_UPLOADED_EPG_HINT": "Από προεπιλογή, οι λίστες Xtream χρησιμοποιούν τον οδηγό προγράμματος του παρόχου σου και οι δικές σου πηγές EPG συμπληρώνουν μόνο τα κανάλια που λείπουν. Ενεργοποίησέ το για να χρησιμοποιείται πρώτα το EPG που ανέβασες.",
"DATA": "Δεδομένα",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"ABOUT": "Πληροφορίες",
"NAV_GENERAL": "Γενικά",
"NAV_PLAYBACK": "Αναπαραγωγή",
"NAV_EPG": "EPG",
"NAV_REMOTE": "Τηλεχειριστήριο",
"NAV_DATA": "Δεδομένα",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Πληροφορίες",
"APP_PREFERENCES": "Προτιμήσεις εφαρμογής",
"HERO_EYEBROW": "Ρυθμίσεις χώρου εργασίας",
"HERO_TITLE": "Διαμορφώστε την εμπειρία του IPTVnator",
"HERO_DESCRIPTION": "Ρυθμίστε την αναπαραγωγή, τους οδηγούς, την απομακρυσμένη πρόσβαση και τη διαχείριση δεδομένων από ένα σημείο, χωρίς να χάσετε το τρέχον στυλ του χώρου εργασίας.",
"SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής",
"DESCRIPTION": "Παρακαλώ προσαρμόστε τις επιλογές από την παρακάτω λίστα και επιβεβαιώστε με το κουμπί αποθήκευσης αλλαγών",
"EPG_URL_LABEL": "EPG Url (*.xml ή *.xml.gz)",
@@ -182,6 +233,12 @@
"EPG_STATUS_STALE": "Χρειάζεται ανανέωση",
"EPG_STATUS_ERROR": "Αποτυχία",
"VIDEO_PLAYER_LABEL": "Προγράμμα αναπαραγωγής βίντεο",
"PLAYER_HTML5": "Πρόγραμμα αναπαραγωγής HTML5",
"PLAYER_VIDEOJS": "Πρόγραμμα αναπαραγωγής Video.js",
"PLAYER_ARTPLAYER": "ArtPlayer",
"PLAYER_EMBEDDED_MPV": "Ενσωματωμένο MPV (πειραματικό, macOS)",
"PLAYER_MPV": "MPV player",
"PLAYER_VLC": "VLC",
"VIDEO_PLAYER_PLACEHOLDER": "Επιλέξτε μια επιλογή",
"VERSION": "Έκδοση",
"SAVE_CHANGES": "Αποθήκευση αλλαγών",
@@ -193,6 +250,8 @@
"SETTINGS_SAVED": "Επιτυχία! Η διαμόρφωση αποθηκεύτηκε.",
"THEME": "Οπτικό θέμα",
"SHOW_CAPTIONS": "Εμφάνιση υπότιτλων",
"SHOW_DASHBOARD": "Εμφάνιση πίνακα ελέγχου",
"SHOW_DASHBOARD_DESCRIPTION": "Διατηρήστε τον πίνακα ελέγχου διαθέσιμο στη γραμμή του χώρου εργασίας. Όταν είναι απενεργοποιημένος, οι Πηγές γίνονται η πρώτη σελίδα του χώρου εργασίας.",
"ADD_EPG_SOURCE": "Προσθήκη πηγής EPG",
"CLEAR_EPG_DATA": "Διαγραφή δεδομένων EPG",
"CLEAR_EPG_DIALOG": {
@@ -207,14 +266,39 @@
"STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)",
"LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής",
"THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής",
"COVER_SIZE": "Μέγεθος εξωφύλλου",
"COVER_SIZE_DESCRIPTION": "Προσαρμόστε το μέγεθος των καρτών αφίσας σε πλέγματα και γραμμές. Τα μεγαλύτερα μεγέθη φαίνονται καλύτερα σε τηλεοράσεις· τα μικρότερα μεγέθη εμφανίζουν περισσότερο περιεχόμενο ανά γραμμή σε μεγάλες οθόνες.",
"COVER_SIZE_SMALL": "Μικρό",
"COVER_SIZE_MEDIUM": "Μεσαίο",
"COVER_SIZE_LARGE": "Μεγάλο",
"STARTUP_BEHAVIOR": "Προβολή εκκίνησης",
"STARTUP_BEHAVIOR_DESCRIPTION": "Επιλέξτε αν το IPTVnator ανοίγει την πρώτη διαθέσιμη προβολή του χώρου εργασίας ή επαναφέρει την τελευταία προβολή σας. Πρώτη προβολή σημαίνει ο πίνακας ελέγχου, όταν είναι ενεργοποιημένος, διαφορετικά οι Πηγές.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Πρώτη διαθέσιμη προβολή",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Επαναφορά τελευταίας προβολής",
"SHOW_CAPTIONS_DESCRIPTION": "Εμφάνιση ή απόκρυψη υποτίτλων από προεπιλογή",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Εμφάνιση υποσέλιδου αναπαραγωγής",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Διατηρήστε ορατή τη γραμμή κατάστασης του εξωτερικού προγράμματος αναπαραγωγής στο κάτω μέρος του χώρου εργασίας στον υπολογιστή.",
"VERSION_DESCRIPTION": "Τρέχουσα έκδοση της εφαρμογής",
"ABOUT_SUBTITLE": "Έκδοση, σύνδεσμοι έργου και τρόποι υποστήριξης του IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Ενσωματωμένος χρόνος εκτέλεσης MPV",
"EMBEDDED_MPV_NOTICE_DESCRIPTION": "Οι εκδόσεις macOS μπορούν να περιλαμβάνουν τις ενσωματωμένες βιβλιοθήκες libmpv και FFmpeg για το πειραματικό ενσωματωμένο πρόγραμμα αναπαραγωγής MPV. Οι χρήστες δεν χρειάζεται να εγκαταστήσουν ξεχωριστά την εφαρμογή MPV· τα εξωτερικά MPV και VLC παραμένουν διαθέσιμα ως εναλλακτικά προγράμματα αναπαραγωγής.",
"COMMUNITY": "Έργο και κοινότητα",
"COMMUNITY_DESCRIPTION": "Δώστε αστέρι στο αποθετήριο, διαβάστε το blog ή συμμετέχετε στην ομάδα Telegram για ενημερώσεις και βοήθεια.",
"LINK_BLOG": "Blog",
"LINK_GITHUB_DESCRIPTION": "Δώστε αστέρι στο αποθετήριο, ανοίξτε ένα ζήτημα ή συνεισφέρετε",
"LINK_BLOG_DESCRIPTION": "Σημειώσεις έκδοσης, συμβουλές και ενημερώσεις του έργου",
"LINK_TELEGRAM_DESCRIPTION": "Ανακοινώσεις, κοινότητα και υποστήριξη",
"SUPPORT_TITLE": "Υποστηρίξτε το έργο",
"SUPPORT_DESCRIPTION": "Το IPTVnator είναι δωρεάν και ανοιχτού κώδικα, δημιουργημένο ανοιχτά. Αν αποτελεί μέρος της καθημερινότητάς σας, μια μικρή συνεισφορά βοηθά να κρατηθεί ζωντανό το έργο και να διαμορφωθεί η συνέχεια — ευχαριστούμε.",
"SUPPORT_SPONSORS_HINT": "Επαναλαμβανόμενο ή εφάπαξ",
"SUPPORT_KOFI_HINT": "Κέρασέ μου έναν καφέ",
"EPG_NOTE": "Προς το παρόν, το EPG είναι διαθέσιμο μόνο στην ηλεκτρονική έκδοση του IPTVnator.",
"EPG_NOTE_URL_TEXT": "Ελέγξτε το εδώ",
"IMPORT_EXPORT_DATA": "Εισαγωγή/εξαγωγή λιστών αναπαραγωγής",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Εξαγωγή ή εισαγωγή λιστών αναπαραγωγής στη μορφή JSON που χρησιμοποιείται στην εφαρμογή.",
"IMPORT_DATA": "Εισαγωγή",
"EXPORT_DATA": "Εξαγωγή",
"EXPORTING_DATA": "Εξαγωγή…",
"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.",
@@ -241,6 +325,8 @@
"VLC_PLAYER_PATH_LABEL": "διαδρομή VLC",
"VLC_PLAYER_PATH_DESCRIPTION": "Ορίστε τη διαδρομή προς το VLC player στο σύστημά σας. Θα χρησιμοποιήσει την παρεχόμενη διαδρομή προς ένα δυαδικό αρχείο VLC αντί για την προεπιλεγμένη διαδρομή για το σύστημά μας.",
"VLC_PLAYER_PATH": "διαδρομή VLC player",
"VLC_REUSE_INSTANCE_LABEL": "Επαναχρησιμοποίηση της παρουσίας VLC",
"VLC_REUSE_INSTANCE_DESCRIPTION": "Όταν είναι ενεργοποιημένη, το ίδιο παράθυρο VLC θα επαναχρησιμοποιηθεί για την αναπαραγωγή διαφορετικών ροών. Όταν είναι απενεργοποιημένη, κάθε ροή ανοίγει σε ένα νέο παράθυρο VLC.",
"REMOTE_CONTROL": "Τηλεχειριστήριο",
"REMOTE_CONTROL_DESCRIPTION": "Ενεργοποιήστε τον τηλεχειρισμό της εφαρμογής.",
"REMOTE_CONTROL_PORT": "Θύρα τηλεχειριστηρίου",
@@ -248,43 +334,102 @@
"REMOTE_CONTROL_URL": "διεύθυνση URL για τηλεχειρισμό",
"REMOTE_CONTROL_URL_DESCRIPTION": "Αποκτήστε πρόσβαση στο τηλεχειριστήριο από άλλη συσκευή στο ίδιο δίκτυο.",
"SHOW_QR_CODE": "Εμφάνιση QR code",
"STATUS_ENABLED": "Ενεργοποιημένο",
"STATUS_DISABLED": "Απενεργοποιημένο",
"SOURCE_SINGULAR": "πηγή",
"SOURCE_PLURAL": "πηγές",
"PORT_RANGE_LABEL": "Θύρα (1-65535)",
"PORT_REQUIRED": "Απαιτείται θύρα",
"PORT_MIN": "Η θύρα πρέπει να είναι τουλάχιστον 1",
"PORT_MAX": "Η θύρα πρέπει να είναι το πολύ 65535",
"PORT_PATTERN": "Επιτρέπονται μόνο αριθμητικά ψηφία",
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset"
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Σκούρο θέμα",
"LIGHT_THEME": "Ανοιχτόχρωμο θέμα",
"SYSTEM_THEME": "Θέμα συστήματος"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Απόκρυψη λίστας καναλιών",
"SHOW_CHANNELS_LIST": "Εμφάνιση λίστας καναλιών",
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Αναζήτηση καναλιού",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
"ALL_CHANNELS": "Όλα τα κανάλια",
"GROUPS": "Ομάδες",
"MANAGE_GROUPS": "Διαχείριση ομάδων",
"MANAGE_GROUPS_HINT": "Χρησιμοποιήστε τη διαχείριση ομάδων για να εμφανίσετε ξανά τις ομάδες.",
"NO_VISIBLE_GROUPS": "Δεν υπάρχουν ορατές ομάδες",
"SEARCH_GROUPS": "Αναζήτηση ομάδων",
"UNGROUPED": "Μη ομαδοποιημένα",
"UNNAMED_CHANNEL": "Κανάλι χωρίς όνομα",
"FAVORITES": "Αγαπημένα",
"NO_FAVORITES": "Δεν υπάρχουν ακόμα αγαπημένα",
"USE_STAR_TO_FAVORITE": "Χρησιμοποιήστε το εικονίδιο με το «αστέρι» για να προσθέσετε κανάλια στα αγαπημένα σας.",
"REMOVE_FAVORITE": "Αφαίρεση από τα αγαπημένα",
"ADD_FAVORITE": "Προσθήκη στα αγαπημένα",
"SHOW_DETAILS": "Εμφάνιση λεπτομερειών καναλιού",
"FAVORITES_UPDATED": "Τα αγαπημένα ενημερώθηκαν!",
"SELECT_CHANNEL_PLAYBACK": "Επιλέξτε ένα κανάλι για να ξεκινήσει η αναπαραγωγή",
"SORT_BY": "Ταξινόμηση κατά",
"SORT_BY_DATE": "Ταξινόμηση κατά Ημερομηνία Προσθήκης (Πρώτα τα Νεότερα)",
"SORT_BY_RATING": "Ταξινόμηση κατά Βαθμολογία",
"SORT_BY_NAME": "Ταξινόμηση κατά Όνομα (A-Z)"
"SORT_BY_NAME": "Ταξινόμηση κατά Όνομα (A-Z)",
"LOAD_MORE": "Φόρτωση περισσότερων",
"REMAINING": "απομένουν",
"LOADING": "Φόρτωση καναλιών...",
"LOADING_MORE": "Φόρτωση περισσότερων...",
"NO_SEARCH_RESULTS": "Δεν βρέθηκαν κανάλια",
"NO_CHANNELS_FOUND": "Δεν βρέθηκαν κανάλια",
"NO_GROUPS_FOUND": "Δεν βρέθηκαν ομάδες",
"TRY_DIFFERENT_SEARCH": "Δοκιμάστε διαφορετικό όρο αναζήτησης",
"DETAILS_DIALOG": {
"TITLE": "Λεπτομέρειες καναλιού",
"DESCRIPTION": "Μεταδεδομένα μόνο για ανάγνωση από το αρχείο της λίστας αναπαραγωγής.",
"SUMMARY": "Σύνοψη",
"TVG": "Μεταδεδομένα TVG",
"CATCHUP": "Catchup",
"HTTP": "Κεφαλίδες HTTP",
"NAME": "Όνομα",
"CHANNEL_ID": "ID καναλιού",
"STREAM_URL": "URL ροής",
"GROUP": "Ομάδα",
"RADIO": "Ραδιόφωνο",
"CATCHUP_AVAILABLE": "Διαθέσιμο Catchup",
"WINDOW": "Χρονικό παράθυρο αρχείου",
"PLAYBACK_SUPPORTED": "Υποστηρίζεται αναπαραγωγή",
"EPG_PARAMS": "Παράμετροι EPG",
"TIMESHIFT": "Timeshift",
"TVG_ID": "TVG ID",
"TVG_NAME": "Όνομα TVG",
"TVG_URL": "URL TVG",
"TVG_LOGO": "Λογότυπο TVG",
"TVG_REC": "TVG rec",
"CATCHUP_TYPE": "Τύπος Catchup",
"CATCHUP_SOURCE": "Πηγή Catchup",
"CATCHUP_DAYS": "Ημέρες Catchup",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "Δεν παρέχεται",
"NOT_AVAILABLE": "Μη διαθέσιμο",
"DAYS_ONE": "1 ημέρα",
"DAYS_OTHER": "{{count}} ημέρες"
},
"GROUP_MANAGEMENT": {
"TITLE": "Διαχείριση ομάδων",
"SELECTED": "Επιλεγμένα",
"SELECT_ALL": "Επιλογή όλων",
"DESELECT_ALL": "Αποεπιλογή όλων",
"SEARCH_PLACEHOLDER": "Αναζήτηση ομάδων...",
"NO_RESULTS": "Δεν βρέθηκαν αντίστοιχες ομάδες",
"SAVE": "Αποθήκευση"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Άνοιγμα λίστας καναλιών",
@@ -300,7 +445,10 @@
"LIVE_NOW": "Ζωντανά τώρα",
"LIVE_STREAM": "Ζωντανή ροή",
"TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Η αναπαραγωγή αρχείου δεν είναι διαθέσιμη",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Αυτός ο πάροχος εκθέτει ιστορικό προγράμματος για το κανάλι, αλλά δεν παρέχει αναπαραγωγή αρχείου/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Ωχ, το EPG δεν είναι διαθέσιμο για την επιλεγμένη ημερομηνία",
"NO_GUIDE_HINT": "Δοκιμάστε άλλη ημερομηνία ή ανανεώστε τη λίστα αναπαραγωγής.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ωχ, το EPG δεν είναι διαθέσιμο για αυτό το κανάλι.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Προσθέστε/αλλάξτε τη διεύθυνση URL της λίστας EPG στις ρυθμίσεις της εφαρμογής.",
"FETCH_EPG": "Ανάκτηση δεδομένων EPG...",
@@ -327,7 +475,10 @@
"TOTAL_CHANNELS": "Σύνολο καναλιών",
"TOTAL_PROGRAMS": "Σύνολο προγραμμάτων",
"UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός",
"RETRY": "Επανάληψη"
"RETRY": "Επανάληψη",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -392,6 +543,22 @@
"AGE": "Ηλικία",
"YOUTUBE_TRAILER": "YouTube Trailer",
"EPISODE": "Επεισόδιο",
"RESUME": "Συνέχιση",
"RESTART": "Από την αρχή",
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"WATCHED": "Έχει προβληθεί",
"IN_PROGRESS": "Σε εξέλιξη",
"MARK_WATCHED": "Σήμανση ως προβληθέν",
"MARK_UNWATCHED": "Σήμανση ως μη προβληθέν",
"EPISODES_WATCHED": "{{count}} από {{total}} επεισόδια προβλήθηκαν",
"DETAIL_FALLBACK": {
"STATUS": "Τα μεταδεδομένα της πύλης δεν είναι διαθέσιμα",
"NOTE": "Δεν παρασχέθηκαν εκτεταμένα μεταδεδομένα από αυτήν την πύλη.",
"STREAM_ID": "ID ροής",
"CATEGORY": "Κατηγορία",
"CONTAINER": "Μορφή",
"SOURCE_TITLE": "Τίτλος πηγής"
},
"ACCOUNT_INFO": {
"TITLE": "Πληροφορίες Λογαριασμού",
"USER_INFO": "Πληροφορίες χρήστη",
@@ -436,6 +603,20 @@
"REMOVE_FROM_FAVORITES": "Αφαίρεση από τα αγαπημένα",
"REMOVED_FROM_FAVORITES": "Αφαιρέθηκε από τα αγαπημένα",
"ADDED_TO_FAVORITES": "Προστέθηκε στα αγαπημένα",
"FAVORITES": "Αγαπημένα",
"RECENTLY_VIEWED": "Πρόσφατα προβληθέντα",
"THIS_PLAYLIST": "Αυτή η λίστα αναπαραγωγής",
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
"LIVE_TV": "Ζωντανή TV",
"MOVIES": "Ταινίες",
"SERIES": "Σειρές",
"SEARCH": "Αναζήτηση",
"NO_FAVORITES": "Δεν υπάρχουν ακόμα αγαπημένα",
"NO_RECENT_ITEMS": "Δεν υπάρχουν πρόσφατα προβληθέντα στοιχεία",
"REMOVE_FROM_RECENT": "Αφαίρεση από τα πρόσφατα προβληθέντα",
"BROWSE_ALL_MOVIES": "Περιήγηση σε όλες τις ταινίες",
"BROWSE_ALL_SERIES": "Περιήγηση σε όλες τις σειρές",
"BROWSE_ALL_LIVE": "Περιήγηση σε όλη τη ζωντανή TV",
"SIDEBAR": {
"MAIN": "Κύριο",
"MOVIES": "Ταινίες",
@@ -451,10 +632,18 @@
"ACCOUNT_INFO": "Πληροφορίες λογαριασμού"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
"TITLE": "Η εισαγωγή ακυρώθηκε",
"DESCRIPTION": "Η εισαγωγή της λίστας αναπαραγωγής ακυρώθηκε. Δοκιμάστε ξανά όταν θέλετε να συνεχίσετε τη φόρτωση αυτής της πηγής."
},
"ACCOUNT_EXPIRED": {
"TITLE": "Ο λογαριασμός έληξε",
"DESCRIPTION": "Αυτός ο λογαριασμός έχει λήξει. Ελέγξτε τα διαπιστευτήρια στις ρυθμίσεις της λίστας αναπαραγωγής ή καταργήστε τον."
},
"ACCOUNT_INACTIVE": {
"TITLE": "Ανενεργός λογαριασμός",
"DESCRIPTION": "Αυτός ο λογαριασμός είναι ανενεργός. Ελέγξτε τα διαπιστευτήρια στις ρυθμίσεις της λίστας αναπαραγωγής ή καταργήστε τον."
},
"UNAUTHORIZED": {
"TITLE": "Ανεξουσιοδότητος",
"DESCRIPTION": "Ελέγξτε τα διαπιστευτήρια στις ρυθμίσεις της λίστας αναπαραγωγής ή καταργήστε τα."
@@ -463,6 +652,10 @@
"TITLE": "Ο διακομιστής δεν βρέθηκε",
"DESCRIPTION": "Παρακαλώ ελέγξτε τα διαπιστευτήρια και τη διεύθυνση URL της πύλης στις ρυθμίσεις της λίστας αναπαραγωγής ή καταργήστε την."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "Η πύλη δεν είναι διαθέσιμη",
"DESCRIPTION": "Δεν ήταν δυνατή η σύνδεση με την πύλη. Ελέγξτε τη διεύθυνση URL του διακομιστή ή δοκιμάστε ξανά όταν η πηγή είναι ξανά διαθέσιμη."
},
"EMPTY_CATEGORY": {
"TITLE": "Κενή κατηγορία",
"DESCRIPTION": "Αυτή η κατηγορία δεν έχει περιεχόμενο, επιλέξτε άλλη κατηγορία ή αλλάξτε τον τύπο περιεχομένου"
@@ -487,7 +680,10 @@
"INITIAL_TITLE": "Αναζήτηση περιεχομένου",
"INITIAL_DESCRIPTION": "Εισαγάγετε τουλάχιστον 3 χαρακτήρες για αναζήτηση σε ζωντανές τηλεοπτικές μεταδόσεις, ταινίες και σειρές.",
"RESULTS_FOUND": "Βρέθηκαν {{count}} αποτελέσματα",
"NO_RESULTS_FOR": "Δεν βρέθηκαν αποτελέσματα για \"{{term}}\""
"NO_RESULTS_FOR": "Δεν βρέθηκαν αποτελέσματα για \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Ομαδοποίηση αποτελεσμάτων ανά λίστα αναπαραγωγής",
"XTREAM_ONLY_NOTE": "Η καθολική αναζήτηση είναι προς το παρόν διαθέσιμη μόνο για λίστες αναπαραγωγής Xtream.",
"VISIBLE_CATEGORIES_ONLY": "Μόνο ορατές κατηγορίες"
},
"STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο",
"COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής",
@@ -496,9 +692,49 @@
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
"PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής",
"LOADING_SEASONS": "Φόρτωση σεζόν...",
"NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια"
"NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια",
"ALL_RADIO": "Όλα τα ραδιόφωνα"
},
"INFORMATION": "Πληροφορίες",
"DOWNLOADS": {
"TITLE": "Λήψεις",
"DOWNLOAD": "Λήψη",
"PLAY_LOCAL": "Τοπική αναπαραγωγή",
"EMPTY": "Δεν υπάρχουν ακόμα λήψεις",
"EMPTY_HINT": "Κατεβάστε ταινίες και επεισόδια για παρακολούθηση εκτός σύνδεσης",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term",
"NOT_AVAILABLE": "Οι λήψεις δεν είναι διαθέσιμες",
"DESKTOP_ONLY": "Αυτή η λειτουργία είναι διαθέσιμη μόνο στην εφαρμογή υπολογιστή",
"NO_FOLDER": "Δεν έχει επιλεγεί φάκελος λήψεων",
"CHANGE_FOLDER": "Αλλαγή φακέλου",
"CLEAR_COMPLETED": "Εκκαθάριση ολοκληρωμένων",
"CLEAR_COMPLETED_DIALOG": {
"TITLE": "Εκκαθάριση ολοκληρωμένων λήψεων",
"MESSAGE": "Αυτό αφαιρεί τις ολοκληρωμένες, αποτυχημένες και ακυρωμένες εγγραφές από την τρέχουσα προβολή λήψεων. Δεν διαγράφει αρχεία που έχουν ήδη ληφθεί στον δίσκο."
},
"SOURCE_PLAYLIST_MISSING": "Αυτή η λήψη δεν μπορεί πλέον να ανοίξει στη βιβλιοθήκη επειδή η πηγαία λίστα αναπαραγωγής αφαιρέθηκε.",
"FILE_NOT_FOUND": "Αυτό το ληφθέν αρχείο δεν είναι πλέον διαθέσιμο στον δίσκο.",
"FILE_ACTION_ERROR": "Δεν ήταν δυνατή η ολοκλήρωση της ενέργειας αρχείου.",
"CANCEL": "Ακύρωση",
"RETRY": "Επανάληψη",
"REMOVE": "Αφαίρεση",
"PLAY": "Αναπαραγωγή",
"REVEAL": "Εμφάνιση στον φάκελο",
"COPY_URL": "Αντιγραφή URL λήψης",
"URL_COPIED": "Η διεύθυνση URL λήψης αντιγράφηκε στο πρόχειρο",
"URL_COPY_FAILED": "Αποτυχία αντιγραφής URL στο πρόχειρο",
"STATUS": {
"QUEUED": "Σε ουρά",
"DOWNLOADING": "Λήψη",
"COMPLETED": "Ολοκληρώθηκε",
"FAILED": "Απέτυχε",
"CANCELED": "Ακυρώθηκε"
}
},
"REMOTE_CONTROL": {
"TITLE": "Τηλεχειριστήριο",
"SUBTITLE": "Ελέγξτε το πρόγραμμα αναπαραγωγής IPTV σας από απόσταση",
@@ -508,7 +744,93 @@
"FOOTER": "IPTVNator Τηλεχειριστήριο"
},
"WORKSPACE": {
"FAVORITES_ONLY": "Μόνο αγαπημένα",
"SORT_CUSTOM": "Προσαρμοσμένη σειρά",
"SORT_DATE_DESC": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)",
"SORT_DATE_ASC": "Ημερομηνία προσθήκης (Πρώτα τα παλαιότερα)",
"SORT_NAME_ASC": "Όνομα A-Z",
"SORT_NAME_DESC": "Όνομα Z-A",
"SORT_LABEL": "Ταξινόμηση: ",
"DASHBOARD": {
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
"GLOBAL_FAVORITES": "Βασικά Αγαπημένα",
"REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό",
"RECENT_SOURCES": "Πρόσφατα χρησιμοποιημένες πηγές",
"MANAGE_ALL": "Διαχείριση όλων",
"RECENTLY_WATCHED": "Πρόσφατα προβληθέντα",
"XTREAM_RECENTLY_ADDED": "Πρόσφατα προστέθηκαν στο Xtream",
"NO_HISTORY": "Δεν βρέθηκαν στοιχεία για αυτό το φίλτρο.",
"NO_HISTORY_HINT": "Το ιστορικό προβολών σας θα εμφανιστεί εδώ μόλις ξεκινήσετε να αναπαράγετε περιεχόμενο από τις λίστες αναπαραγωγής σας.",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U",
"STALKER_PORTAL": "Stalker Portal",
"PROVIDER": "Πάροχος",
"TYPE_LIVE": "Ζωντανά",
"TYPE_MOVIE": "Ταινία",
"TYPE_SERIES": "Σειρά",
"NOT_YET_SYNCED": "Δεν έχει συγχρονιστεί ακόμα"
},
"SHELL": {
"BRAND_ALT": "IPTVnator",
"RAIL_DASHBOARD": "Πίνακας ελέγχου",
"OPEN_DASHBOARD": "Άνοιγμα πίνακα ελέγχου",
"RAIL_SOURCES": "Πηγές",
"OPEN_SOURCES": "Άνοιγμα πηγών",
"RAIL_GLOBAL_FAVORITES": "Αγαπημένα",
"RAIL_GLOBAL_RECENT": "Πρόσφατα προβληθέντα",
"RAIL_SETTINGS": "Ρυθμίσεις",
"OPEN_SETTINGS": "Άνοιγμα ρυθμίσεων",
"RAIL_LIBRARY": "Βιβλιοθήκη",
"RAIL_MOVIES": "Ταινίες",
"RAIL_LIVE": "Ζωντανή TV",
"RAIL_SERIES": "Σειρές",
"RAIL_RECENTLY_ADDED": "Πρόσφατα προστέθηκαν",
"RAIL_SEARCH": "Σύνθετη αναζήτηση",
"RAIL_RECENT": "Πρόσφατα προβληθέντα",
"RAIL_FAVORITES": "Αγαπημένα",
"RAIL_DOWNLOADS": "Λήψεις",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"RAIL_ALL_CHANNELS": "Όλα τα κανάλια",
"RAIL_GROUPS": "Ομάδες",
"RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας",
"SEARCH_LABEL": "Αναζήτηση",
"SEARCH_SOURCES_PLACEHOLDER": "Αναζήτηση πηγών (όλες οι λίστες αναπαραγωγής)...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Αναζήτηση σε αυτή τη λίστα αναπαραγωγής...",
"SEARCH_SECTION_PLACEHOLDER": "Αναζήτηση σε αυτή την ενότητα...",
"FILTER_SECTION_PLACEHOLDER": "Φιλτράρισμα αυτής της ενότητας...",
"SEARCH_STATUS_LOADED_ONLY": "Μόνο φορτωμένα κανάλια",
"COMMAND_PALETTE_SHORTCUT": "Παλέτα εντολών (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "Άνοιγμα παλέτας εντολών",
"ADD_PLAYLIST_ALL": "Προσθήκη λίστας αναπαραγωγής",
"ADD_PLAYLIST": "Προσθήκη λίστας αναπαραγωγής",
"OPEN_DOWNLOADS": "Άνοιγμα λήψεων",
"OPEN_CONTEXT_ACTIONS": "Άνοιγμα ενεργειών ενότητας",
"NO_SECTION_ACTIONS": "Δεν υπάρχουν ενέργειες για αυτή την ενότητα",
"PLAYLIST_INFO": "Πληροφορίες λίστας αναπαραγωγής",
"ACCOUNT_INFO": "Πληροφορίες λογαριασμού",
"REFRESH_PLAYLIST": "Ανανέωση λίστας αναπαραγωγής",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "Εκκαθάριση ολοκληρωμένων λήψεων (όλες οι λίστες αναπαραγωγής)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Εκκαθάριση ολοκληρωμένων λήψεων για όλες τις λίστες αναπαραγωγής",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Εκκαθάριση ολοκληρωμένων λήψεων (αυτή η λίστα αναπαραγωγής)",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Εκκαθάριση ολοκληρωμένων λήψεων για αυτή τη λίστα αναπαραγωγής",
"CLEAR_RECENTLY_VIEWED_SECTION": "Εκκαθάριση πρόσφατα προβληθέντων",
"CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Εκκαθάριση πρόσφατα προβληθέντων για αυτή την ενότητα",
"CLEAR_RECENTLY_VIEWED_TYPE": "Εκκαθάριση πρόσφατα προβληθέντων {{type}}",
"CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Εκκαθάριση πρόσφατα προβληθέντων {{type}};",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Όλα τα στοιχεία {{type}} από αυτή τη λίστα αναπαραγωγής θα αφαιρεθούν από το ιστορικό πρόσφατα προβληθέντων. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL": "Όλα τα στοιχεία {{type}} σε όλες τις λίστες αναπαραγωγής θα αφαιρεθούν από το ιστορικό πρόσφατα προβληθέντων. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"CLEAR_FAVORITES_TYPE": "Εκκαθάριση αγαπημένων {{type}}",
"CLEAR_FAVORITES_DIALOG_TITLE": "Αφαίρεση όλων των αγαπημένων {{type}};",
"CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST": "Όλα τα αγαπημένα {{type}} από αυτή τη λίστα αναπαραγωγής θα αφαιρεθούν. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"CLEAR_FAVORITES_DIALOG_MESSAGE_ALL": "Όλα τα αγαπημένα {{type}} σε όλες τις λίστες αναπαραγωγής θα αφαιρεθούν. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"XTREAM_CODE": "Xtream Code",
"STALKER_PORTAL": "Stalker Portal",
"CHANNELS_COUNT": "{{count}} κανάλια",
"NO_SOURCES_AVAILABLE": "Δεν υπάρχουν διαθέσιμες πηγές",
"ONE_SOURCE_AVAILABLE": "1 διαθέσιμη πηγή",
"SOURCES_AVAILABLE": "{{count}} διαθέσιμες πηγές",
"GO_TO_DASHBOARD": "Back to dashboard",
"GO_TO_SOURCES": "Go to Sources",
"DROP_OVERLAY_TITLE": "Drop to add playlist",
@@ -518,21 +840,129 @@
"DROP_OVERLAY_REJECTED_EMPTY": "That file is empty",
"DROP_OVERLAY_REJECTED_READ_ERROR": "Could not read that file",
"DROP_IMPORT_SUCCESS": "Importing \"{{title}}\"…",
"XTREAM_IMPORT_TITLE": "Συγχρονισμός λίστας αναπαραγωγής",
"XTREAM_IMPORT_REMOTE_BADGE": "Απομακρυσμένη πηγή",
"XTREAM_IMPORT_LOCAL_BADGE": "Τοπική βιβλιοθήκη",
"XTREAM_IMPORT_DETAIL_REMOTE": "Αναμονή απάντησης από τον πάροχο.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Προετοιμασία γρηγορότερης εναλλαγής λίστας αναπαραγωγής για την επόμενη επίσκεψη.",
"XTREAM_IMPORT_PREPARING": "Προετοιμασία τοπικής βιβλιοθήκης...",
"XTREAM_IMPORT_LOADING": "Λήψη δεδομένων λίστας αναπαραγωγής από την πηγή...",
"XTREAM_IMPORT_SAVING": "Αποθήκευση δεδομένων λίστας αναπαραγωγής τοπικά...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Επαναφορά αγαπημένων από την τοπική βιβλιοθήκη...",
"XTREAM_IMPORT_RESTORING_RECENT": "Επαναφορά πρόσφατα προβληθέντων από την τοπική βιβλιοθήκη...",
"XTREAM_IMPORT_PROGRESS": "{{type}} εισήχθησαν: {{current}} / {{total}}",
"XTREAM_IMPORT_CANCEL": "Διακοπή συγχρονισμού",
"XTREAM_IMPORT_CANCELLING": "Διακοπή συγχρονισμού...",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Αναζήτηση σε όλες τις λίστες αναπαραγωγής Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "Άνοιγμα επικάλυψης καθολικής αναζήτησης",
"PLAYLIST_SEARCH_LABEL": "Σύνθετη αναζήτηση σε αυτή τη λίστα αναπαραγωγής",
"PLAYLIST_SEARCH_DESCRIPTION": "Άνοιγμα σύνθετης αναζήτησης λίστας αναπαραγωγής",
"OPEN_GLOBAL_FAVORITES_LABEL": "Άνοιγμα βασικών αγαπημένων",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "Πλοήγηση στα συγκεντρωτικά αγαπημένα",
"OPEN_DOWNLOADS_LABEL": "Άνοιγμα λήψεων",
"OPEN_DOWNLOADS_DESCRIPTION": "Πλοήγηση στην προβολή λήψεων",
"OPEN_GLOBAL_RECENT_LABEL": "Άνοιγμα πρόσφατα προβληθέντων",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Άνοιγμα της σελίδας καθολικών πρόσφατα προβληθέντων",
"OPEN_SETTINGS_LABEL": "Άνοιγμα ρυθμίσεων",
"OPEN_SETTINGS_DESCRIPTION": "Πλοήγηση στις ρυθμίσεις της εφαρμογής",
"OPEN_SOURCES_LABEL": "Διαχείριση πηγών",
"OPEN_SOURCES_DESCRIPTION": "Πλοήγηση στη διαχείριση πηγών",
"OPEN_DASHBOARD_LABEL": "Άνοιγμα πίνακα ελέγχου",
"OPEN_DASHBOARD_DESCRIPTION": "Πλοήγηση στον πίνακα ελέγχου",
"ADD_PLAYLIST_LABEL": "Προσθήκη νέας πηγής",
"ADD_PLAYLIST_DESCRIPTION": "Άνοιγμα διαλόγου προσθήκης λίστας αναπαραγωγής",
"ADD_PLAYLIST_M3U_LABEL": "Add M3U playlist",
"ADD_PLAYLIST_M3U_DESCRIPTION": "Open add dialog on the M3U file or URL tab",
"ADD_PLAYLIST_XTREAM_LABEL": "Add Xtream Codes playlist",
"ADD_PLAYLIST_XTREAM_DESCRIPTION": "Open add dialog on the Xtream Codes tab",
"ADD_PLAYLIST_STALKER_LABEL": "Add Stalker portal",
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab"
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab",
"REFRESH_PLAYLIST_LABEL": "Ανανέωση λίστας αναπαραγωγής",
"REFRESH_PLAYLIST_DESCRIPTION": "Επανασυγχρονισμός δεδομένων από την απομακρυσμένη πηγή",
"PLAYLIST_INFO_LABEL": "Λεπτομέρειες λίστας αναπαραγωγής",
"PLAYLIST_INFO_DESCRIPTION": "Εμφάνιση πληροφοριών της τρέχουσας λίστας αναπαραγωγής",
"ACCOUNT_INFO_LABEL": "Πληροφορίες λογαριασμού",
"ACCOUNT_INFO_DESCRIPTION": "Εμφάνιση στοιχείων λογαριασμού διακομιστή Xtream",
"OPEN_VIEW_DESCRIPTION": "Πλοήγηση στο {{view}}",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Εκκαθάριση όλων των ορατών στοιχείων {{type}} σε αυτή την προβολή",
"OPEN_MULTI_EPG_DESCRIPTION": "Άνοιγμα της προβολής EPG πολλαπλών καναλιών",
"GO_TO_VOD_LABEL": "Ταινίες",
"GO_TO_VOD_DESCRIPTION": "Μετάβαση στην ενότητα ταινιών",
"GO_TO_LIVE_LABEL": "Ζωντανή TV",
"GO_TO_LIVE_DESCRIPTION": "Μετάβαση στην ενότητα ζωντανής TV",
"GO_TO_SERIES_LABEL": "Σειρές",
"GO_TO_SERIES_DESCRIPTION": "Μετάβαση στην ενότητα σειρών",
"SWITCH_PLAYER_LABEL": "Εναλλαγή προγράμματος αναπαραγωγής σε {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Ορισμός του {{name}} ως ενεργό πρόγραμμα αναπαραγωγής βίντεο",
"SWITCH_PLAYER_FEEDBACK": "Το πρόγραμμα αναπαραγωγής ορίστηκε σε {{name}}"
},
"EXTERNAL_PLAYBACK_CLOSE": "Close player"
"XTREAM_REFRESH_TITLE": "Ανανέωση λίστας αναπαραγωγής",
"XTREAM_REFRESH_COLLECTING_DATA": "Διατήρηση των δεδομένων της βιβλιοθήκης σας...",
"XTREAM_REFRESH_DELETING_CONTENT": "Αφαίρεση αποθηκευμένων ροών...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Αφαίρεση αποθηκευμένων κατηγοριών...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Τα αγαπημένα, το ιστορικό προβολής, οι κρυφές κατηγορίες και η πρόοδος αναπαραγωγής ετοιμάζονται για επαναφορά μετά τον συγχρονισμό.",
"XTREAM_REFRESH_PROGRESS": "Τοπικές εγγραφές που επεξεργάστηκαν: {{current}} / {{total}}",
"RAIL_RADIO": "Ραδιόφωνο"
},
"SOURCES": {
"TITLE": "Πηγές",
"SUBTITLE": "Φιλτράρισμα και ταξινόμηση λιστών αναπαραγωγής",
"TYPE": "Τύπος",
"ALL": "Όλα",
"M3U_PLAYLISTS": "Λίστες αναπαραγωγής M3U",
"XTREAM_PLAYLISTS": "Λίστες αναπαραγωγής Xtream",
"STALKER_PLAYLISTS": "Λίστες αναπαραγωγής Stalker",
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
"PLAYLIST_COUNT_ONE": "1 λίστα αναπαραγωγής",
"PLAYLIST_COUNT_OTHER": "{{count}} λίστες αναπαραγωγής"
},
"CONTEXT": {
"CATEGORIES": "Κατηγορίες",
"MOVIE_CATEGORIES": "Κατηγορίες ταινιών",
"SERIES_CATEGORIES": "Κατηγορίες σειρών",
"LIVE_CATEGORIES": "Κατηγορίες ζωντανής TV",
"XTREAM_SYNCING_MOVIES": "Συγχρονισμός κατηγοριών ταινιών...",
"XTREAM_SYNCING_SERIES": "Συγχρονισμός κατηγοριών σειρών...",
"XTREAM_SYNCING_LIVE": "Συγχρονισμός κατηγοριών ζωντανής TV...",
"XTREAM_SOURCE": "Πηγή Xtream",
"STALKER_PORTAL": "Stalker portal",
"MANAGE_CATEGORIES": "Διαχείριση κατηγοριών",
"LOAD_CATEGORIES_ERROR": "Δεν είναι δυνατή η φόρτωση των κατηγοριών",
"LOAD_CATEGORIES_ERROR_HINT": "Δοκιμάστε ξανά ή επαληθεύστε τις ρυθμίσεις της πύλης.",
"FILTER_BY_TYPE": "Φιλτράρισμα ανά τύπο",
"ITEM_COUNT_ONE": "1 στοιχείο",
"ITEM_COUNT_OTHER": "{{count}} στοιχεία",
"SEARCH": "Αναζήτηση",
"SEARCH_CATEGORIES": "Αναζήτηση κατηγοριών",
"RADIO_CATEGORIES": "Κατηγορίες ραδιοφώνου"
},
"COMMAND_PALETTE": {
"TITLE": "Παλέτα εντολών",
"SUBTITLE": "Καθολικές και συμφραζόμενες ενέργειες",
"SEARCH_PLACEHOLDER": "Πληκτρολογήστε για φιλτράρισμα εντολών",
"SEARCH_LABEL": "Φιλτράρισμα εντολών",
"EMPTY": "Δεν βρέθηκαν εντολές",
"GROUP_GLOBAL": "Καθολικές ενέργειες",
"GROUP_PLAYLIST": "Αυτή η λίστα αναπαραγωγής",
"GROUP_VIEW": "Τρέχουσα προβολή",
"GROUP_SECTION": "Αυτή η ενότητα",
"GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες"
},
"GLOBAL_FAVORITES": {
"TITLE": "Βασικά Αγαπημένα",
"SUBTITLE": "Ζωντανή TV από όλες τις λίστες αναπαραγωγής",
"SEARCH_PLACEHOLDER": "Αναζήτηση αγαπημένων...",
"NO_RESULTS": "Δεν υπάρχουν αποτελέσματα",
"NO_RESULTS_HINT": "Δοκιμάστε διαφορετικό όρο αναζήτησης",
"EMPTY_TITLE": "Δεν υπάρχουν ακόμα αγαπημένα ζωντανής TV",
"EMPTY_HINT": "Επισημάνετε με αστέρι ζωντανά κανάλια από λίστες αναπαραγωγής M3U, Xtream ή Stalker για να εμφανιστούν εδώ.",
"EMPTY_PLAYER": "Επιλέξτε ένα κανάλι για να ξεκινήσει η παρακολούθηση",
"NO_PLAYLISTS_TITLE": "No favorites to show",
"NO_PLAYLISTS_BODY": "Add a playlist to start collecting favorites from live channels, movies, and series.",
"NO_ITEMS_TITLE": "No favorites yet",
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here."
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here.",
"UNKNOWN_CHANNEL": "Άγνωστο"
},
"GLOBAL_RECENT": {
"NO_PLAYLISTS_TITLE": "Nothing watched yet",
@@ -540,12 +970,5 @@
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
}
},
"DOWNLOADS": {
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term"
}
}
+45 -10
View File
@@ -109,7 +109,7 @@
"REFRESH_XTREAM": "Refresh Xtream playlist from remote",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Refresh Xtream Playlist",
"MESSAGE": "This will delete locally cached categories and streams and re-import everything from the remote server. Your favorites and recently viewed items will be preserved. Are you sure you want to continue?",
"MESSAGE": "This will delete the local Xtream cache and re-import categories and streams from the remote server. Favorites, recently viewed items, hidden category visibility, and playback progress are restored after a successful re-import when matching content still exists. If the server cannot be reached, the restore remains pending until a later successful refresh. Continue?",
"STARTED": "Refresh started. Navigating to playlist...",
"ERROR": "Failed to refresh playlist. Please try again.",
"IN_PROGRESS": "Refreshing playlist...",
@@ -202,6 +202,8 @@
"EPG_SOURCES_DESCRIPTION": "Add one or more URLs as EPG sources",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Prefer your EPG sources for Xtream playlists",
"PREFER_UPLOADED_EPG_HINT": "By default, Xtream playlists use the program guide from your provider, and your uploaded EPG only fills missing channels. Turn this on to use your uploaded EPG first instead.",
"DATA": "Data",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
@@ -244,6 +246,8 @@
"NEW_VERSION_AVAILABLE": "There is a new version available",
"LATEST_VERSION": "You are using the latest version",
"STREAM_FORMAT": "Stream Format",
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK": "Start MPV/VLC with double-click",
"OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before MPV or VLC playback starts.",
"LANGUAGE": "Language",
"SETTINGS_SAVED": "Success! Configuration was saved.",
"THEME": "Visual theme",
@@ -264,6 +268,11 @@
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
"LANGUAGE_DESCRIPTION": "Select language of the application",
"THEME_DESCRIPTION": "Select visual theme of the application",
"COVER_SIZE": "Cover size",
"COVER_SIZE_DESCRIPTION": "Adjust the size of poster cards across grids and rails. Larger sizes are easier to see on TVs; smaller sizes show more content per row on big monitors.",
"COVER_SIZE_SMALL": "Small",
"COVER_SIZE_MEDIUM": "Medium",
"COVER_SIZE_LARGE": "Large",
"STARTUP_BEHAVIOR": "Startup view",
"STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "First available view",
@@ -318,6 +327,8 @@
"VLC_PLAYER_PATH_LABEL": "VLC path",
"VLC_PLAYER_PATH_DESCRIPTION": "Set path to VLC player in your system. It will use the provided path to a VLC binary instead of the default path for our system.",
"VLC_PLAYER_PATH": "VLC player path",
"VLC_REUSE_INSTANCE_LABEL": "Reuse VLC instance",
"VLC_REUSE_INSTANCE_DESCRIPTION": "When enabled, the same VLC window will be reused for playing different streams. When disabled, each stream opens in a new VLC window.",
"REMOTE_CONTROL": "Remote control",
"REMOTE_CONTROL_DESCRIPTION": "Enable remote control of the application.",
"REMOTE_CONTROL_PORT": "Remote control port",
@@ -343,6 +354,11 @@
"LIGHT_THEME": "Light theme",
"SYSTEM_THEME": "System theme"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Hide channels list",
"SHOW_CHANNELS_LIST": "Show channels list",
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Search channel",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist",
@@ -372,6 +388,7 @@
"LOADING_MORE": "Loading more...",
"NO_SEARCH_RESULTS": "No channels found",
"NO_CHANNELS_FOUND": "No channels found",
"NO_GROUPS_FOUND": "No groups found",
"TRY_DIFFERENT_SEARCH": "Try a different search term",
"DETAILS_DIALOG": {
"TITLE": "Channel details",
@@ -432,7 +449,8 @@
"TIMESHIFT_AVAILABLE": "Timeshift is available, click to play",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archive playback is unavailable",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "This provider exposes schedule history for the channel, but does not expose playable archive/catch-up playback.",
"EPG_NOT_AVAILABLE_DATE": "Ooops, EPG is not available for the selected date",
"EPG_NOT_AVAILABLE_DATE": "No program guide",
"NO_GUIDE_HINT": "Try a different date or refresh the playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the URL of the EPG list in the settings of the app.",
"FETCH_EPG": "Fetching EPG data...",
@@ -459,7 +477,10 @@
"TOTAL_CHANNELS": "Total channels",
"TOTAL_PROGRAMS": "Total programs",
"UP_TO_DATE": "EPG is up-to-date, no sync needed",
"RETRY": "Retry"
"RETRY": "Retry",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -573,6 +594,7 @@
"DESELECT_ALL": "Deselect All",
"SEARCH_PLACEHOLDER": "Search categories...",
"NO_RESULTS": "No matching categories found",
"NO_CATEGORIES": "No categories available",
"SAVE": "Save"
}
},
@@ -654,7 +676,7 @@
},
"EMPTY_LIST_VIEW": {
"TITLE": "No results found",
"NO_SEARCH_RESULTS": "Nothing found, try to change you search request"
"NO_SEARCH_RESULTS": "Nothing found, try to change your search request."
},
"SEARCH_VIEW": {
"INITIAL_TITLE": "Search for content",
@@ -672,7 +694,8 @@
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
"PLAYBACK_ERROR": "Failed to get playback URL",
"LOADING_SEASONS": "Loading seasons...",
"NO_EPISODES": "No episodes available"
"NO_EPISODES": "No episodes available",
"ALL_RADIO": "All radio"
},
"INFORMATION": "Information",
"DOWNLOADS": {
@@ -757,7 +780,7 @@
"OPEN_DASHBOARD": "Open dashboard",
"RAIL_SOURCES": "Sources",
"OPEN_SOURCES": "Open sources",
"RAIL_GLOBAL_FAVORITES": "Global favorites",
"RAIL_GLOBAL_FAVORITES": "Favorites",
"RAIL_GLOBAL_RECENT": "Recently viewed",
"RAIL_SETTINGS": "Settings",
"OPEN_SETTINGS": "Open settings",
@@ -871,8 +894,18 @@
"GO_TO_LIVE_LABEL": "Live TV",
"GO_TO_LIVE_DESCRIPTION": "Jump to live TV section",
"GO_TO_SERIES_LABEL": "Series",
"GO_TO_SERIES_DESCRIPTION": "Jump to series section"
}
"GO_TO_SERIES_DESCRIPTION": "Jump to series section",
"SWITCH_PLAYER_LABEL": "Switch player to {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player",
"SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}"
},
"XTREAM_REFRESH_TITLE": "Refreshing playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Preserving your library data...",
"XTREAM_REFRESH_DELETING_CONTENT": "Removing cached streams...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Removing cached categories...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Keeping favorites, watch history, hidden categories, and playback progress ready for restore after sync.",
"XTREAM_REFRESH_PROGRESS": "Local records processed: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Sources",
@@ -903,7 +936,8 @@
"ITEM_COUNT_ONE": "1 item",
"ITEM_COUNT_OTHER": "{{count}} items",
"SEARCH": "Search",
"SEARCH_CATEGORIES": "Search categories"
"SEARCH_CATEGORIES": "Search categories",
"RADIO_CATEGORIES": "Radio Categories"
},
"COMMAND_PALETTE": {
"TITLE": "Command palette",
@@ -914,7 +948,8 @@
"GROUP_GLOBAL": "Global actions",
"GROUP_PLAYLIST": "This playlist",
"GROUP_VIEW": "Current view",
"GROUP_SECTION": "This section"
"GROUP_SECTION": "This section",
"GROUP_RECENT": "Recently used"
},
"GLOBAL_FAVORITES": {
"TITLE": "Global Favorites",
+641 -129
View File
@@ -1,5 +1,10 @@
{
"CANCEL": "Cancelar",
"CLEAR": "Borrar",
"CLOSE": "Cerrar",
"SHOW_MORE": "Más",
"SHOW_LESS": "Menos",
"PLAYLISTS": "Listas de reproducción",
"HOME": {
"TITLE": "IPTVnator",
"SUBTITLE": "Selecciona una lista de reproducción del listado o añade una nueva lista al cargar archivos o por una url",
@@ -8,6 +13,7 @@
"FILE_UPLOAD": "Añadir y cargar archivo",
"URL_UPLOAD": "Añadir por url",
"TEXT_IMPORT": "Importar como texto",
"XTREME_IMPORT": "Agregar Xtream Code",
"STALKER_PORTAL_IMPORT": "Agregar portal de acosador"
},
"PLAYLISTS": {
@@ -19,45 +25,6 @@
"FEATURE_M3U": "Archivos M3U/M3U8",
"FEATURE_XTREAM": "Xtream Codes",
"FEATURE_STALKER": "Portal Stalker",
"NO_RESULTS_TITLE": "No se encontraron listas",
"NO_RESULTS_DESCRIPTION": "Ninguna lista coincide con su búsqueda. Intente ajustar los términos de búsqueda o borrar el filtro para ver todas las listas.",
"ADDED_VIA_URL": "Añadido por carga de URL. URL:",
"ADDED_VIA_FILE": "Añadido por carga de archivo",
"CHANNELS": "Canales",
"ADDED": "Añadido",
"SHOW_DETAILS": "Mostrar detalles de la lista",
"REMOVE": "Quitar lista",
"INFO_DIALOG": {
"PLAYLIST_DETAILS": "Detalles de la lista",
"TITLE": "Título",
"FROM_URL": "Añadida desde la URL",
"IMPORT_DATE": "Fecha de importación",
"CHANNELS": "Número de canales",
"CLOSE": "Cerrar",
"SAVE": "Guardar",
"USER_AGENT": "Agente de usuario",
"ORIGINAL_FILENAME": "Nombre de archivo original",
"FILE_PATH": "Ruta de archivo",
"UPDATE_FAILED": "Última actualización de la lista fallida",
"AUTO_UPDATE": "Auto-actualizar",
"AUTO_UPDATE_DESCRIPTION": "Si la función de auto-actualizar se activa, la lista de reproducción se refrescará automáticamente cada vez que se inicia la app.",
"CUSTOM_USER_AGENT": "Algunos proveedores de IPTV necesitan especificar el agente de usuario para reproducir su lista de reproducción.",
"PASSWORD": "Contraseña",
"SERVER_URL": "URL del servidor",
"USERNAME": "Nombre de usuario",
"MAC_ADDRESS": "Dirección MAC"
},
"REMOVE_DIALOG": {
"TITLE": "Quitar lista de reproducción",
"MESSAGE": "¿Estás seguro de quitar esta lista de reproducción por completo?"
},
"UPDATED": "Actualizado",
"REFRESH": "Actualizar lista",
"GLOBAL_FAVORITES": "Favoritos globales",
"GLOBAL_FAVORITES_DESCRIPTION": "Lista autogenerada con los favoritos agregados de todas las listas",
"MY_PLAYLISTS": "Mis listas",
"PLAYLIST_UPDATE_SUCCESS": "¡Éxito! La lista de reproducción se actualizó correctamente.",
"STALKER_PORTAL": "Stalker Portal",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
@@ -67,6 +34,7 @@
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
@@ -87,36 +55,143 @@
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen."
"NO_RESULTS_TITLE": "No se encontraron listas",
"NO_RESULTS_DESCRIPTION": "Ninguna lista coincide con su búsqueda. Intente ajustar los términos de búsqueda o borrar el filtro para ver todas las listas.",
"ADDED_VIA_URL": "Añadido por carga de URL. URL:",
"ADDED_VIA_FILE": "Añadido por carga de archivo",
"XTREAM_PLAYLIST": "Lista Xtream",
"STALKER_PORTAL": "Stalker Portal",
"CHANNELS": "Canales",
"ADDED": "Añadido",
"SHOW_DETAILS": "Mostrar detalles de la lista",
"REMOVE": "Quitar lista",
"SEARCH_PLAYLISTS": "Buscar listas por título",
"INFO_DIALOG": {
"PLAYLIST_DETAILS": "Detalles de la lista",
"TITLE": "Título",
"FROM_URL": "Añadida desde la URL",
"IMPORT_DATE": "Fecha de importación",
"CHANNELS": "Número de canales",
"CLOSE": "Cerrar",
"SAVE": "Guardar",
"USER_AGENT": "Agente de usuario",
"ORIGINAL_FILENAME": "Nombre de archivo original",
"FILE_PATH": "Ruta de archivo",
"UPDATE_FAILED": "Última actualización de la lista fallida",
"AUTO_UPDATE": "Auto-actualizar",
"AUTO_UPDATE_DESCRIPTION": "Si la función de auto-actualizar se activa, la lista de reproducción se refrescará automáticamente cada vez que se inicia la app.",
"CUSTOM_USER_AGENT": "Algunos proveedores de IPTV necesitan especificar el agente de usuario para reproducir su lista de reproducción.",
"EXPORT_PLAYLIST": "Exportar lista como m3u",
"SERVER_URL": "URL del servidor",
"USERNAME": "Nombre de usuario",
"PASSWORD": "Contraseña",
"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.",
"COPY_URL": "Copiar URL al portapapeles",
"URL_COPIED": "URL copiada al portapapeles"
},
"REMOVE_DIALOG": {
"TITLE": "Quitar lista de reproducción",
"MESSAGE": "¿Estás seguro de quitar esta lista de reproducción por completo?",
"SUCCESS": "La lista de reproducción se eliminó correctamente",
"IN_PROGRESS": "Eliminando lista de reproducción…",
"DELETING_FAVORITES": "Eliminando favoritos…",
"DELETING_RECENT": "Eliminando vistos recientemente…",
"DELETING_PROGRESS": "Eliminando progreso de reproducción…",
"DELETING_DOWNLOADS": "Eliminando descargas…",
"DELETING_CONTENT": "Eliminando contenido en caché…",
"DELETING_CATEGORIES": "Eliminando categorías…",
"DELETING_PLAYLIST": "Eliminando registro de la lista…"
},
"UPDATED": "Actualizado",
"REFRESH": "Actualizar lista",
"REFRESH_XTREAM": "Actualizar lista Xtream desde el servidor remoto",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Actualizar lista Xtream",
"MESSAGE": "Esto eliminará la caché local de Xtream y volverá a importar las categorías y los streams desde el servidor remoto. Los favoritos, los elementos vistos recientemente, la visibilidad de las categorías ocultas y el progreso de reproducción se restauran tras una nueva importación correcta cuando aún existe el contenido equivalente. Si no se puede contactar con el servidor, la restauración queda pendiente hasta una actualización posterior con éxito. ¿Continuar?",
"STARTED": "Actualización iniciada. Navegando a la lista de reproducción…",
"ERROR": "No se pudo actualizar la lista de reproducción. Inténtalo de nuevo.",
"IN_PROGRESS": "Actualizando lista de reproducción…",
"COLLECTING_DATA": "Recopilando favoritos y vistos recientemente…",
"DELETING_CONTENT": "Eliminando contenido en caché…",
"DELETING_CATEGORIES": "Eliminando categorías en caché…"
},
"GLOBAL_FAVORITES": "Favoritos globales",
"GLOBAL_FAVORITES_DESCRIPTION": "Lista autogenerada con los favoritos agregados de todas las listas",
"MY_PLAYLISTS": "Mis listas",
"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_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",
"AUTO_REFRESH_ENABLED": "Auto-actualización activada"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Arrastre & suelte archivos aquí",
"OR": "o",
"CHOOSE_PLAYLIST": "Escoga una lista de reproducción",
"BROWSE": "Explorar archivos",
"REJECTED": "El archivo fue rechazado, formato de archivo no compatible {{filename}}."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "Lista de reproducción (m3u, m3u8)",
"ADD_PLAYLIST": "Añadir lista"
"ADD_PLAYLIST": "Añadir lista",
"CORS_NOTE": "Nota: para evitar problemas de CORS, la aplicación usa un servicio público de proxy CORS. Si quieres cargar una lista de reproducción con datos sensibles, es más seguro importarla desde un archivo.",
"ERROR_FETCH_FAILED": "No se pudo obtener la lista de reproducción. Verifica la URL e inténtalo de nuevo.",
"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."
},
"TEXT_IMPORT": {
"LABEL": "Inserte la playlist m3u(8) como texto",
"BUTTON_LABEL": "Importar"
},
"STALKER_PORTAL": {
"ADD": "Agregar",
"TITLE": "Título",
"SERVER_URL": "URL del servidor",
"MAC_ADDRESS": "Dirección Mac",
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c)."
},
"FILTER_BY_TYPE": "Filtrar por tipo",
"IMPORTED_AS_TEXT": "Importado como texto",
"FILTER_BY_NAME": "Filtrar por nombre",
"PARSING_ERROR": "Error: Error al analizar, no es una lista de reproducción válida:",
"XTREAM_PLAYLIST": {
"TITLE": "Título de la lista",
"SERVER_URL": "URL del servidor",
"USERNAME": "Nombre de usuario",
"PASSWORD": "Contraseña",
"ADD": "Agregar",
"TEST_CREDENTIALS": "Probar credenciales",
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com o https://example.com:4324)."
}
},
"STALKER_PORTAL": {
"TITLE": "Título",
"MAC_ADDRESS": "Dirección Mac",
"SERIAL_NUMBER": "Número de serie (opcional)",
"SERIAL_NUMBER_HINT": "Úsalo si ya estás registrado con el proveedor",
"DEVICE_ID_1": "ID de dispositivo 1 (opcional)",
"DEVICE_ID_2": "ID de dispositivo 2 (opcional)",
"DEVICE_ID_HINT": "64 caracteres hexadecimales — úsalo si el proveedor requiere un ID de dispositivo específico",
"SIGNATURE_1": "Firma 1 (opcional)",
"SIGNATURE_2": "Firma 2 (opcional)",
"SIGNATURE_HINT": "64 caracteres hexadecimales — úsalo si el proveedor requiere firmas de verificación del dispositivo",
"SERVER_URL": "URL del servidor",
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com/c o https://example.com/stalker_portal/c).",
"ADD": "Agregar",
"VALIDATING": "Validando portal…"
},
"FILTER_BY_NAME": "Filtrar por nombre",
"FILTER_BY_TYPE": "Filtrar por tipo",
"FILTER_AND_SORT": "Filtrar y ordenar",
"SORT_PLAYLISTS": "Ordenar listas",
"SORT_BY": "Ordenar por",
"SORT_OPTIONS": {
"NAME_ASC": "Nombre (A-Z)",
"NAME_DESC": "Nombre (Z-A)",
"NEWEST": "Fecha de adición (más recientes primero)",
"OLDEST": "Fecha de adición (más antiguas primero)",
"CUSTOM_ORDER": "Orden personalizado (Drag & Drop)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Importado como texto",
"PARSING_ERROR": "Error: Error al analizar, no es una lista de reproducción válida:"
},
"SETTINGS": {
"TITLE": "Ajustes",
@@ -125,13 +200,23 @@
"PLAYBACK": "Reproduccion",
"EPG_SOURCES": "Fuentes EPG",
"EPG_SOURCES_DESCRIPTION": "Agrega una o varias URL como fuentes EPG",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Preferir tus fuentes EPG para listas Xtream",
"PREFER_UPLOADED_EPG_HINT": "De forma predeterminada, las listas Xtream usan la guía de programas de tu proveedor, y tus fuentes EPG solo rellenan los canales que faltan. Activa esta opción para usar antes tu EPG.",
"DATA": "Datos",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"ABOUT": "Acerca de",
"NAV_GENERAL": "General",
"NAV_PLAYBACK": "Reproduccion",
"NAV_EPG": "EPG",
"NAV_REMOTE": "Remoto",
"NAV_DATA": "Datos",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Acerca de",
"APP_PREFERENCES": "Preferencias de la app",
"HERO_EYEBROW": "Ajustes del espacio de trabajo",
@@ -151,6 +236,7 @@
"PLAYER_HTML5": "Reproductor HTML5",
"PLAYER_VIDEOJS": "Reproductor Video.js",
"PLAYER_ARTPLAYER": "ArtPlayer",
"PLAYER_EMBEDDED_MPV": "MPV integrado (experimental, macOS)",
"PLAYER_MPV": "Reproductor MPV",
"PLAYER_VLC": "VLC",
"VIDEO_PLAYER_PLACEHOLDER": "Selecciona una opción",
@@ -164,8 +250,9 @@
"SETTINGS_SAVED": "¡Completado! La configuración se guardó.",
"THEME": "Tema visual",
"SHOW_CAPTIONS": "Mostrar subtítulos",
"REMOVE_ALL": "Delete all playlists and playlist data",
"REMOVE_ALL_BUTTON": "Delete all",
"SHOW_DASHBOARD": "Mostrar panel",
"SHOW_DASHBOARD_DESCRIPTION": "Mantén el panel disponible en la barra del espacio de trabajo. Cuando se desactiva, Fuentes pasa a ser la primera página del espacio de trabajo.",
"ADD_EPG_SOURCE": "Agregar fuente EPG",
"CLEAR_EPG_DATA": "Borrar datos EPG",
"CLEAR_EPG_DIALOG": {
"TITLE": "Borrar datos EPG",
@@ -174,6 +261,47 @@
"EPG_DATA_CLEARED": "Los datos EPG se borraron",
"EPG_DATA_CLEAR_FAILED": "No se pudieron borrar los datos EPG. Inténtalo de nuevo.",
"CLEARING_EPG_DATA": "Borrando…",
"EPG_URL_ERROR": "El valor no es una URL válida.",
"VIDEO_PLAYER_DESCRIPTION": "Seleccionar reproductor de vídeo predeterminado",
"STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)",
"LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación",
"THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.",
"COVER_SIZE": "Tamaño de portada",
"COVER_SIZE_DESCRIPTION": "Ajusta el tamaño de las tarjetas de pósteres en cuadrículas y carruseles. Los tamaños grandes se ven mejor en TVs; los pequeños muestran más contenido por fila en monitores grandes.",
"COVER_SIZE_SMALL": "Pequeño",
"COVER_SIZE_MEDIUM": "Mediano",
"COVER_SIZE_LARGE": "Grande",
"STARTUP_BEHAVIOR": "Vista al iniciar",
"STARTUP_BEHAVIOR_DESCRIPTION": "Elige si IPTVnator abre la primera vista disponible del espacio de trabajo o restaura la última vista a nivel de sección. Primera vista significa Panel cuando está activado, de lo contrario Fuentes.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primera vista disponible",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurar última vista",
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar u ocultar subtítulos por defecto",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar pie de reproducción actual",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantén visible la barra de estado del reproductor externo en la parte inferior del espacio de trabajo en escritorio.",
"VERSION_DESCRIPTION": "Versión actual de la aplicación.",
"ABOUT_SUBTITLE": "Versión, enlaces del proyecto y formas de apoyar a IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime de MPV integrado",
"EMBEDDED_MPV_NOTICE_DESCRIPTION": "Las compilaciones de macOS pueden incluir las bibliotecas libmpv y FFmpeg integradas para el reproductor MPV integrado experimental. Los usuarios no necesitan instalar la app MPV por separado; MPV externo y VLC siguen disponibles como reproductores alternativos.",
"COMMUNITY": "Proyecto y comunidad",
"COMMUNITY_DESCRIPTION": "Da una estrella al repositorio, lee el blog o únete al grupo de Telegram para novedades y ayuda.",
"LINK_BLOG": "Blog",
"LINK_GITHUB_DESCRIPTION": "Da una estrella al repo, abre un issue o contribuye",
"LINK_BLOG_DESCRIPTION": "Notas de versión, consejos y novedades del proyecto",
"LINK_TELEGRAM_DESCRIPTION": "Anuncios, comunidad y soporte",
"SUPPORT_TITLE": "Apoya el proyecto",
"SUPPORT_DESCRIPTION": "IPTVnator es gratuito y de código abierto, desarrollado a la vista. Si forma parte de tu rutina diaria, una pequeña contribución ayuda a mantener vivo el proyecto y a definir lo que viene — gracias.",
"SUPPORT_SPONSORS_HINT": "Recurrente o única vez",
"SUPPORT_KOFI_HINT": "Invítame un café",
"EPG_NOTE": "Actualmente, EPG está disponible sólo en la versión electrónica de IPTVnator.",
"EPG_NOTE_URL_TEXT": "Compruébalo aquí",
"IMPORT_EXPORT_DATA": "Importar/exportar copias de seguridad",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Exporte o restaure copias de seguridad con instantáneas M3U, metadatos de conexión de Xtream/Stalker, estado del usuario y URLs de EPG.",
"IMPORT_DATA": "Importar",
"EXPORT_DATA": "Exportar",
"EXPORTING_DATA": "Exportando…",
"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.",
"REMOVE_DIALOG": {
"TITLE": "Delete all playlists?",
"MESSAGE": "Delete all {{count}} playlists from the app. This also removes stored playlist data on this device.",
@@ -187,31 +315,18 @@
"BACKUP_HINT": "Use Export first if you want a restorable backup before deleting.",
"CONFIRM": "Delete everything"
},
"LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación",
"IMPORT_ERROR": "Error de importación, intente con otro archivo.",
"PLAYLISTS_REMOVED": "All playlists and playlist data were removed.",
"VERSION_DESCRIPTION": "Versión actual de la aplicación.",
"MPV_PLAYER_PATH": "ruta del reproductor mpv",
"MPV_PLAYER_PATH_LABEL": "Ruta de mpv",
"MPV_PLAYER_PATH_DESCRIPTION": "Establece la ruta al reproductor mpv en tu sistema. Se usará la ruta proporcionada al binario de mpv en lugar de la encontrada en $PATH.",
"MPV_PLAYER_PATH": "ruta del reproductor mpv",
"MPV_REUSE_INSTANCE_LABEL": "Reutilizar instancia de MPV",
"MPV_REUSE_INSTANCE_DESCRIPTION": "Cuando esta opción está activada, se reutiliza la misma ventana de MPV para reproducir distintos streams. Cuando está desactivada, cada stream se abre en una nueva ventana de MPV.",
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar u ocultar subtítulos por defecto",
"IMPORT_DATA": "Importar",
"ADD_EPG_SOURCE": "Agregar fuente EPG",
"IMPORT_ERROR": "Error de importación, intente con otro archivo.",
"EPG_URL_ERROR": "El valor no es una URL válida.",
"EXPORT_DATA": "Exportar",
"EXPORTING_DATA": "Exportando…",
"VIDEO_PLAYER_DESCRIPTION": "Seleccionar reproductor de vídeo predeterminado",
"IMPORT_EXPORT_DATA": "Importar/exportar copias de seguridad",
"VLC_PLAYER_PATH_LABEL": "ruta VLC",
"VLC_PLAYER_PATH_DESCRIPTION": "Establezca la ruta al reproductor VLC en su sistema. \nUtilizará la ruta proporcionada a un binario VLC en lugar de la ruta predeterminada para nuestro sistema.",
"THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.",
"EPG_NOTE": "Actualmente, EPG está disponible sólo en la versión electrónica de IPTVnator.",
"VLC_PLAYER_PATH": "Ruta del reproductor VLC",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Exporte o restaure copias de seguridad con instantáneas M3U, metadatos de conexión de Xtream/Stalker, estado del usuario y URLs de EPG.",
"EPG_NOTE_URL_TEXT": "Compruébalo aquí",
"MPV_PLAYER_PATH_LABEL": "Ruta de mpv",
"REMOVE_ALL_DESCRIPTION": "Delete every M3U, Xtream, and Stalker playlist stored in the app.",
"VLC_REUSE_INSTANCE_LABEL": "Reutilizar instancia de VLC",
"VLC_REUSE_INSTANCE_DESCRIPTION": "Cuando esta opción está activada, se reutiliza la misma ventana de VLC para reproducir distintos streams. Cuando está desactivada, cada stream se abre en una nueva ventana de VLC.",
"REMOTE_CONTROL": "Control remoto",
"REMOTE_CONTROL_DESCRIPTION": "Habilita el control remoto de la aplicación.",
"REMOTE_CONTROL_PORT": "Puerto de control remoto",
@@ -228,55 +343,118 @@
"PORT_MIN": "El puerto debe ser al menos 1",
"PORT_MAX": "El puerto debe ser como máximo 65535",
"PORT_PATTERN": "Solo se permiten digitos",
"STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)",
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset"
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Tema oscuro",
"LIGHT_THEME": "Tema claro",
"SYSTEM_THEME": "Tema del sistema"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ocultar lista de canales",
"SHOW_CHANNELS_LIST": "Mostrar lista de canales",
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Buscar canal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción",
"ALL_CHANNELS": "Todos los canales",
"GROUPS": "Grupos",
"MANAGE_GROUPS": "Gestionar grupos",
"MANAGE_GROUPS_HINT": "Usa gestionar grupos para volver a mostrar los grupos.",
"NO_VISIBLE_GROUPS": "Sin grupos visibles",
"SEARCH_GROUPS": "Buscar grupos",
"UNGROUPED": "Desagrupado",
"UNNAMED_CHANNEL": "Canal sin nombre",
"FAVORITES": "Favoritos",
"NO_FAVORITES": "Sin favoritos",
"USE_STAR_TO_FAVORITE": "Por favor, use el ícono de la 'estrella' para añadir canales a la lista.",
"REMOVE_FAVORITE": "Quitar de favoritos",
"FAVORITES_UPDATED": "¡Se actualizaron los favoritos!"
"ADD_FAVORITE": "Agregar a favoritos",
"SHOW_DETAILS": "Mostrar detalles del canal",
"FAVORITES_UPDATED": "¡Se actualizaron los favoritos!",
"SELECT_CHANNEL_PLAYBACK": "Selecciona un canal para iniciar la reproducción",
"SORT_BY": "Ordenar por",
"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)",
"LOAD_MORE": "Cargar más",
"REMAINING": "restantes",
"LOADING": "Cargando canales…",
"LOADING_MORE": "Cargando más…",
"NO_SEARCH_RESULTS": "No se encontraron canales",
"NO_CHANNELS_FOUND": "No se encontraron canales",
"NO_GROUPS_FOUND": "No se encontraron grupos",
"TRY_DIFFERENT_SEARCH": "Prueba con otro término de búsqueda",
"DETAILS_DIALOG": {
"TITLE": "Detalles del canal",
"DESCRIPTION": "Metadatos de solo lectura analizados desde el archivo de la lista de reproducción.",
"SUMMARY": "Resumen",
"TVG": "Metadatos TVG",
"CATCHUP": "Catchup",
"HTTP": "Cabeceras HTTP",
"NAME": "Nombre",
"CHANNEL_ID": "ID del canal",
"STREAM_URL": "URL de transmisión",
"GROUP": "Grupo",
"RADIO": "Radio",
"CATCHUP_AVAILABLE": "Catchup disponible",
"WINDOW": "Ventana de archivo",
"PLAYBACK_SUPPORTED": "Reproducción compatible",
"EPG_PARAMS": "Parámetros EPG",
"TIMESHIFT": "Timeshift",
"TVG_ID": "TVG ID",
"TVG_NAME": "Nombre TVG",
"TVG_URL": "URL TVG",
"TVG_LOGO": "Logo TVG",
"TVG_REC": "TVG rec",
"CATCHUP_TYPE": "Tipo de Catchup",
"CATCHUP_SOURCE": "Fuente de Catchup",
"CATCHUP_DAYS": "Días de Catchup",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "No proporcionado",
"NOT_AVAILABLE": "No disponible",
"DAYS_ONE": "1 día",
"DAYS_OTHER": "{{count}} días"
},
"GROUP_MANAGEMENT": {
"TITLE": "Gestionar grupos",
"SELECTED": "Seleccionados",
"SELECT_ALL": "Seleccionar todo",
"DESELECT_ALL": "Deseleccionar todo",
"SEARCH_PLACEHOLDER": "Buscar grupos…",
"NO_RESULTS": "No se encontraron grupos coincidentes",
"SAVE": "Guardar"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Abrir lista de canales",
"TOGGLE_FAVORITE_FLAG": "Alternar bandera favorita",
"OPEN_EPG_LIST": "Abrir lista EPG",
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG"
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG",
"SHOW_INFO": "Mostrar información del programa (presiona I)"
},
"EPG": {
"TITLE": "Guía electrónica de programación",
"NEXT_DAY": "Día siguiente",
"PREVIOUS_DAY": "Día anterior",
"LIVE_NOW": "Ahora en vivo",
"LIVE_STREAM": "Transmisión en vivo",
"TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "La reproducción del archivo no está disponible",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Este proveedor expone el historial de programación del canal, pero no permite la reproducción del archivo/Catchup.",
"EPG_NOT_AVAILABLE_DATE": "Vaya, no está disponible la EPG de esa fecha",
"NO_GUIDE_HINT": "Prueba otra fecha o actualiza la lista de reproducción.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Vaya, no está disponible la EPG del canal.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Por favor añada/cargue la url de la lista EPG en los ajustes de la app.",
"FETCH_EPG": "Obteniendo datos de EPG...",
"ERROR": "¡Vaya! Esta EPG no se pudo cargar.",
"DOWNLOAD_SUCCESS": "La EPG se ha descargado correctamente.",
"NO_PROGRAM_INFO": "No hay información del programa disponible",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Detalles sobre el programa de TV",
"TITLE": "Título",
@@ -284,8 +462,23 @@
"PARENTAL_RATING_SYSTEM": "Sistema de calificación de contenidos televisivos",
"DESCRIPTION": "Descripción",
"LANGUAGE": "Idioma",
"SHOW_PROGRAM_DETAILS": "Mostrar detalles del programa"
}
"SHOW_PROGRAM_DETAILS": "Mostrar detalles del programa",
"EPISODE": "Episodio",
"DURATION": "Duración"
},
"FETCH_SUCCESS": "EPG obtenida correctamente",
"IMPORT_PROGRESS": "Importación de EPG",
"CHANNELS": "canales",
"PROGRAMS": "programas",
"LOADING": "Cargando",
"QUEUED": "En cola",
"TOTAL_CHANNELS": "Total de canales",
"TOTAL_PROGRAMS": "Total de programas",
"UP_TO_DATE": "EPG actualizada, no se necesita sincronización",
"RETRY": "Reintentar",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -320,18 +513,35 @@
"ABOUT_ARIA": "Acerca de la aplicación",
"SUPPORT_PROJECT": "Proyecto de apoyo"
},
"ABOUT": {
"TITLE": "Acerca de IPTVnator",
"DESCRIPTION": "Reproductor IPTV multiplataforma con múltiples funciones, como soporte de listas de reproducción m3u y m3u8, favoritos, guía de TV, archivo/Catchup de TV y más.",
"VERSION": "Versión",
"GITHUB_TOOLTIP": "Repositorio ITPVnator en GitHub",
"BUY_ME_A_COFFEE_TOOLTIP": "Buy me a coffee",
"TWITTER_TOOLTIP": "Compartir en Twitter"
},
"UPDATE_AVAILABLE": "Actualización disponible",
"REFRESH": "Actualizar",
"XTREAM": {
"RELEASE_DATE": "Fecha de lanzamiento",
"ACTORS": "Actores",
"PLAY": "Reproducir",
"GENRE": "Género",
"DURATION": "Duración",
"COUNTRY": "País",
"OPENING_PLAYER": "Abriendo reproductor…",
"NAME": "Nombre",
"RELEASE_DATE": "Fecha de lanzamiento",
"GENRE": "Género",
"COUNTRY": "País",
"ACTORS": "Actores",
"DIRECTOR": "Director",
"DURATION": "Duración",
"CAST": "Elenco",
"EPISODE_RUN_TIME": "Tiempo de ejecución del episodio",
"RATING": "Clasificación",
"IMDB_RATING": "Calificación IMDB",
"KINOPOISK_RATING": "Calificación de Kinopoisk",
"DESCRIPTION": "Descripción",
"YEAR": "Año",
"AGE": "Edad",
"YOUTUBE_TRAILER": "Tráiler de YouTube",
"EPISODE": "Episodio",
"RESUME": "Continuar",
"RESTART": "Reiniciar",
@@ -341,60 +551,150 @@
"MARK_WATCHED": "Marcar como visto",
"MARK_UNWATCHED": "Marcar como no visto",
"EPISODES_WATCHED": "{{count}} de {{total}} episodios vistos",
"YEAR": "Año",
"YOUTUBE_TRAILER": "Tráiler de YouTube",
"DESCRIPTION": "Descripción",
"KINOPOISK_RATING": "Calificación de Kinopoisk",
"IMDB_RATING": "Calificación IMDB",
"EPISODE_RUN_TIME": "Tiempo de ejecución del episodio"
"DETAIL_FALLBACK": {
"STATUS": "Metadatos del portal no disponibles",
"NOTE": "Este portal no proporcionó metadatos extendidos.",
"STREAM_ID": "ID del stream",
"CATEGORY": "Categoría",
"CONTAINER": "Formato",
"SOURCE_TITLE": "Título de la fuente"
},
"ACCOUNT_INFO": {
"TITLE": "Información de la cuenta",
"USER_INFO": "Información del usuario",
"STATUS": "Estado",
"USERNAME": "Nombre de usuario",
"ACTIVE_CONNECTIONS": "Conexiones activas",
"CREATED": "Creada",
"EXPIRES": "Caduca",
"TRIAL_ACCOUNT": "Cuenta de prueba",
"YES": "Sí",
"NO": "No",
"ALLOWED_FORMATS": "Formatos permitidos",
"SERVER_INFO": "Información del servidor",
"LIVE_TV": "TV en vivo",
"MOVIES": "Películas",
"TV_SERIES": "Series de TV",
"URL": "URL",
"PROTOCOL": "Protocolo",
"TIMEZONE": "Zona horaria",
"SERVER_TIME": "Hora del servidor",
"PORTS": "Puertos",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
},
"CATEGORY_MANAGEMENT": {
"TITLE": "Gestionar categorías",
"LOADING": "Cargando categorías…",
"SELECTED": "Seleccionadas",
"SELECT_ALL": "Seleccionar todo",
"DESELECT_ALL": "Deseleccionar todo",
"SEARCH_PLACEHOLDER": "Buscar categorías…",
"NO_RESULTS": "No se encontraron categorías coincidentes",
"NO_CATEGORIES": "No hay categorías disponibles",
"SAVE": "Guardar"
}
},
"PORTALS": {
"ALL_CATEGORIES": "Todas las categorias",
"MY_FAVORITES": "Mis favoritos",
"ADD_TO_FAVORITES": "Agregar a los favoritos",
"REMOVE_FROM_FAVORITES": "Quitar de favoritos",
"MY_FAVORITES": "Mis favoritos",
"COPY_STREAM_URL": "Copiar URL de transmisión",
"REMOVED_FROM_FAVORITES": "Eliminado de favoritos",
"ADDED_TO_FAVORITES": "Añadido a favoritos",
"FAVORITES": "Favoritos",
"RECENTLY_VIEWED": "Vistos recientemente",
"THIS_PLAYLIST": "Esta lista de reproducción",
"ALL_PLAYLISTS": "Todas las listas",
"LIVE_TV": "TV en vivo",
"MOVIES": "Películas",
"SERIES": "Series",
"SEARCH": "Buscar",
"NO_FAVORITES": "Aún no hay favoritos",
"NO_RECENT_ITEMS": "No hay elementos vistos recientemente",
"REMOVE_FROM_RECENT": "Quitar de vistos recientemente",
"BROWSE_ALL_MOVIES": "Ver todas las películas",
"BROWSE_ALL_SERIES": "Ver todas las series",
"BROWSE_ALL_LIVE": "Ver toda la TV en vivo",
"SIDEBAR": {
"MAIN": "Principal",
"MOVIES": "Películas",
"LIVE_TV": "TV en vivo",
"SERIES": "Series",
"LIBRARY": "Biblioteca",
"SEARCH": "Buscar",
"RECENT": "Reciente",
"RECENTLY_ADDED": "Añadidos recientemente",
"FAVORITES": "Favoritos",
"HOME": "Inicio",
"PLAYLIST_INFO": "Información de la lista",
"ACCOUNT_INFO": "Información de la cuenta"
},
"ERROR_VIEW": {
"DELETE": "Borrar",
"IMPORT_CANCELLED": {
"TITLE": "Importación cancelada",
"DESCRIPTION": "La importación de la lista de reproducción se canceló. Reintenta cuando quieras continuar cargando esta fuente."
},
"ACCOUNT_EXPIRED": {
"TITLE": "Cuenta caducada",
"DESCRIPTION": "Esta cuenta ha caducado. \nVerifique las credenciales en la configuración de la lista de reproducción o elimínelas."
},
"HOME": "Hogar",
"EMPTY_CATEGORY": {
"TITLE": "categoría vacía",
"DESCRIPTION": "Esta categoría no tiene contenido, seleccione otra categoría o cambie el tipo de contenido"
"ACCOUNT_INACTIVE": {
"TITLE": "Cuenta inactiva",
"DESCRIPTION": "Esta cuenta está inactiva. Verifica las credenciales en la configuración de la lista de reproducción o elimínala."
},
"UNAUTHORIZED": {
"TITLE": "No autorizado",
"DESCRIPTION": "Verifique las credenciales en la configuración de la lista de reproducción o elimínelas."
},
"NOT_FOUND": {
"TITLE": "Portal no encontrado",
"DESCRIPTION": "Verifica las credenciales y la URL del portal en la configuración de la lista de reproducción o elimínala."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "Portal no disponible",
"DESCRIPTION": "No se pudo contactar con el portal. Verifica la URL del servidor o reintenta cuando la fuente esté disponible de nuevo."
},
"EMPTY_CATEGORY": {
"TITLE": "categoría vacía",
"DESCRIPTION": "Esta categoría no tiene contenido, seleccione otra categoría o cambie el tipo de contenido"
},
"UNKNOWN_ERROR": {
"TITLE": "algo salió mal",
"DESCRIPTION": "Error desconocido, inténtalo de nuevo más tarde."
},
"PLAYLIST_SETTINGS": "Configuración de lista de reproducción"
"NO_CATEGORY_SELECTED": {
"TITLE": "Ninguna categoría seleccionada",
"DESCRIPTION": "Selecciona una categoría para ver el contenido"
},
"PLAYLIST_SETTINGS": "Configuración de lista de reproducción",
"HOME": "Hogar",
"DELETE": "Borrar"
},
"STREAM_URL_COPIED": "URL de transmisión copiada al portapapeles",
"EMPTY_LIST_VIEW": {
"TITLE": "No se encontraron resultados",
"NO_SEARCH_RESULTS": "No se encontró nada, intenta cambiar tu solicitud de búsqueda."
},
"REMOVED_FROM_FAVORITES": "Eliminado de favoritos",
"ADDED_TO_FAVORITES": "Añadido a favoritos",
"SEARCH_VIEW": {
"INITIAL_TITLE": "Buscar contenido",
"INITIAL_DESCRIPTION": "Ingresa al menos 3 caracteres para buscar en TV en vivo, películas y series.",
"RESULTS_FOUND": "Se encontraron {{count}} resultados",
"NO_RESULTS_FOR": "No se encontraron resultados para \"{{term}}\""
}
"NO_RESULTS_FOR": "No se encontraron resultados para \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Agrupar resultados por lista de reproducción",
"XTREAM_ONLY_NOTE": "La búsqueda global está disponible actualmente solo para listas Xtream.",
"VISIBLE_CATEGORIES_ONLY": "Solo categorías visibles"
},
"STREAM_URL_COPIED": "URL de transmisión copiada al portapapeles",
"COPY_STREAM_URL": "Copiar URL de transmisión",
"SELECT_CATEGORY": "Selecciona una categoría",
"PAGE": "Página",
"CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor",
"PLAYBACK_ERROR": "No se pudo obtener la URL de reproducción",
"LOADING_SEASONS": "Cargando temporadas…",
"NO_EPISODES": "No hay episodios disponibles",
"ALL_RADIO": "Todas las radios"
},
"ABOUT": {
"TITLE": "Acerca de IPTVnator",
"VERSION": "Versión",
"TWITTER_TOOLTIP": "Compartir en Twitter",
"GITHUB_TOOLTIP": "Repositorio ITPVnator en GitHub",
"BUY_ME_A_COFFEE_TOOLTIP": "Buy me a coffee"
},
"REFRESH": "Actualizar",
"UPDATE_AVAILABLE": "Actualización disponible",
"INFORMATION": "Información",
"DOWNLOADS": {
"TITLE": "Descargas",
@@ -402,31 +702,135 @@
"PLAY_LOCAL": "Reproducir local",
"EMPTY": "Sin descargas aún",
"EMPTY_HINT": "Descarga películas y episodios para ver sin conexión",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term",
"NOT_AVAILABLE": "Descargas no disponibles",
"DESKTOP_ONLY": "Esta función solo está disponible en la aplicación de escritorio",
"NO_FOLDER": "No se ha seleccionado carpeta de descarga",
"CHANGE_FOLDER": "Cambiar carpeta",
"CLEAR_COMPLETED": "Limpiar completados",
"CLEAR_COMPLETED_DIALOG": {
"TITLE": "Borrar descargas completadas",
"MESSAGE": "Esto elimina las entradas completadas, fallidas y canceladas de la vista actual de descargas. No borra ningún archivo que ya se haya descargado al disco."
},
"SOURCE_PLAYLIST_MISSING": "Esta descarga ya no se puede abrir en la biblioteca porque su lista de reproducción de origen fue eliminada.",
"FILE_NOT_FOUND": "Este archivo descargado ya no está disponible en el disco.",
"FILE_ACTION_ERROR": "No se pudo completar la acción del archivo.",
"CANCEL": "Cancelar",
"RETRY": "Reintentar",
"REMOVE": "Eliminar",
"PLAY": "Reproducir",
"REVEAL": "Mostrar en carpeta",
"COPY_URL": "Copiar URL de descarga",
"URL_COPIED": "URL de descarga copiada al portapapeles",
"URL_COPY_FAILED": "No se pudo copiar la URL al portapapeles",
"STATUS": {
"QUEUED": "En cola",
"DOWNLOADING": "Descargando",
"COMPLETED": "Completado",
"FAILED": "Fallido",
"CANCELED": "Cancelado"
},
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term"
}
},
"REMOTE_CONTROL": {
"TITLE": "Control remoto",
"SUBTITLE": "Controla tu reproductor IPTV de forma remota",
"UP_CHANNEL": "Canal arriba",
"DOWN_CHANNEL": "Canal abajo",
"HEADER": "Control remoto de IPTVnator",
"FOOTER": "Control remoto de IPTVnator"
},
"WORKSPACE": {
"FAVORITES_ONLY": "Solo favoritos",
"SORT_CUSTOM": "Orden personalizado",
"SORT_DATE_DESC": "Fecha de adición (más recientes primero)",
"SORT_DATE_ASC": "Fecha de adición (más antiguas primero)",
"SORT_NAME_ASC": "Nombre A-Z",
"SORT_NAME_DESC": "Nombre Z-A",
"SORT_LABEL": "Ordenar: ",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar viendo",
"UNTITLED_SOURCE": "Fuente sin título",
"GLOBAL_FAVORITES": "Favoritos globales",
"REMOVE_FROM_HISTORY": "Quitar del historial",
"RECENT_SOURCES": "Fuentes usadas recientemente",
"MANAGE_ALL": "Gestionar todo",
"RECENTLY_WATCHED": "Vistos recientemente",
"XTREAM_RECENTLY_ADDED": "Añadidos recientemente en Xtream",
"NO_HISTORY": "No se encontraron elementos para este filtro.",
"NO_HISTORY_HINT": "Tu historial de visualización aparecerá aquí cuando empieces a reproducir contenido de tus listas.",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U",
"STALKER_PORTAL": "Stalker Portal",
"PROVIDER": "Proveedor",
"TYPE_LIVE": "En vivo",
"TYPE_MOVIE": "Película",
"TYPE_SERIES": "Serie",
"NOT_YET_SYNCED": "Aún no sincronizado"
},
"SHELL": {
"BRAND_ALT": "IPTVnator",
"RAIL_DASHBOARD": "Panel",
"OPEN_DASHBOARD": "Abrir panel",
"RAIL_SOURCES": "Fuentes",
"OPEN_SOURCES": "Abrir fuentes",
"RAIL_GLOBAL_FAVORITES": "Favoritos",
"RAIL_GLOBAL_RECENT": "Vistos recientemente",
"RAIL_SETTINGS": "Ajustes",
"OPEN_SETTINGS": "Abrir ajustes",
"RAIL_LIBRARY": "Biblioteca",
"RAIL_MOVIES": "Películas",
"RAIL_LIVE": "TV en vivo",
"RAIL_SERIES": "Series",
"RAIL_RECENTLY_ADDED": "Añadidos recientemente",
"RAIL_SEARCH": "Búsqueda avanzada",
"RAIL_RECENT": "Vistos recientemente",
"RAIL_FAVORITES": "Favoritos",
"RAIL_DOWNLOADS": "Descargas",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"RAIL_ALL_CHANNELS": "Todos los canales",
"RAIL_GROUPS": "Grupos",
"RAIL_CONTEXT_ACTIONS": "Acciones de la sección",
"SEARCH_LABEL": "Buscar",
"SEARCH_SOURCES_PLACEHOLDER": "Buscar fuentes (todas las listas)…",
"SEARCH_PLAYLIST_PLACEHOLDER": "Buscar en esta lista de reproducción…",
"SEARCH_SECTION_PLACEHOLDER": "Buscar en esta sección…",
"FILTER_SECTION_PLACEHOLDER": "Filtrar esta sección…",
"SEARCH_STATUS_LOADED_ONLY": "Solo canales cargados",
"COMMAND_PALETTE_SHORTCUT": "Paleta de comandos (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "Abrir paleta de comandos",
"ADD_PLAYLIST_ALL": "Agregar lista de reproducción",
"ADD_PLAYLIST": "Agregar lista de reproducción",
"OPEN_DOWNLOADS": "Abrir descargas",
"OPEN_CONTEXT_ACTIONS": "Abrir acciones de la sección",
"NO_SECTION_ACTIONS": "Sin acciones para esta sección",
"PLAYLIST_INFO": "Información de la lista",
"ACCOUNT_INFO": "Información de la cuenta",
"REFRESH_PLAYLIST": "Actualizar lista de reproducción",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "Borrar descargas completadas (todas las listas)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Borrar descargas completadas de todas las listas",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Borrar descargas completadas (esta lista)",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Borrar descargas completadas de esta lista",
"CLEAR_RECENTLY_VIEWED_SECTION": "Borrar vistos recientemente",
"CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Borrar vistos recientemente de esta sección",
"CLEAR_RECENTLY_VIEWED_TYPE": "Borrar vistos recientemente {{type}}",
"CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "¿Borrar vistos recientemente {{type}}?",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Todos los elementos {{type}} de esta lista se eliminarán de tu historial de vistos recientemente. Esta acción no se puede deshacer.",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL": "Todos los elementos {{type}} de todas las listas se eliminarán de tu historial de vistos recientemente. Esta acción no se puede deshacer.",
"CLEAR_FAVORITES_TYPE": "Borrar favoritos {{type}}",
"CLEAR_FAVORITES_DIALOG_TITLE": "¿Eliminar todos los favoritos {{type}}?",
"CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST": "Todos los favoritos {{type}} de esta lista se eliminarán. Esta acción no se puede deshacer.",
"CLEAR_FAVORITES_DIALOG_MESSAGE_ALL": "Todos los favoritos {{type}} de todas las listas se eliminarán. Esta acción no se puede deshacer.",
"XTREAM_CODE": "Xtream Code",
"STALKER_PORTAL": "Stalker Portal",
"CHANNELS_COUNT": "{{count}} canales",
"NO_SOURCES_AVAILABLE": "No hay fuentes disponibles",
"ONE_SOURCE_AVAILABLE": "1 fuente disponible",
"SOURCES_AVAILABLE": "{{count}} fuentes disponibles",
"GO_TO_DASHBOARD": "Back to dashboard",
"GO_TO_SOURCES": "Go to Sources",
"DROP_OVERLAY_TITLE": "Drop to add playlist",
@@ -436,21 +840,129 @@
"DROP_OVERLAY_REJECTED_EMPTY": "That file is empty",
"DROP_OVERLAY_REJECTED_READ_ERROR": "Could not read that file",
"DROP_IMPORT_SUCCESS": "Importing \"{{title}}\"…",
"XTREAM_IMPORT_TITLE": "Sincronizando lista de reproducción",
"XTREAM_IMPORT_REMOTE_BADGE": "Fuente remota",
"XTREAM_IMPORT_LOCAL_BADGE": "Biblioteca local",
"XTREAM_IMPORT_DETAIL_REMOTE": "Esperando la respuesta del proveedor.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparando un cambio de lista más rápido para la próxima visita.",
"XTREAM_IMPORT_PREPARING": "Preparando biblioteca local…",
"XTREAM_IMPORT_LOADING": "Obteniendo datos de la lista desde la fuente…",
"XTREAM_IMPORT_SAVING": "Guardando datos de la lista localmente…",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restaurando favoritos desde la biblioteca local…",
"XTREAM_IMPORT_RESTORING_RECENT": "Restaurando vistos recientemente desde la biblioteca local…",
"XTREAM_IMPORT_PROGRESS": "{{type}} importados: {{current}} / {{total}}",
"XTREAM_IMPORT_CANCEL": "Detener sincronización",
"XTREAM_IMPORT_CANCELLING": "Deteniendo sincronización…",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Buscar en todas las listas Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "Abrir el panel de búsqueda global",
"PLAYLIST_SEARCH_LABEL": "Búsqueda avanzada en esta lista",
"PLAYLIST_SEARCH_DESCRIPTION": "Abrir búsqueda avanzada de la lista",
"OPEN_GLOBAL_FAVORITES_LABEL": "Abrir favoritos globales",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "Ir a los favoritos agregados",
"OPEN_DOWNLOADS_LABEL": "Abrir descargas",
"OPEN_DOWNLOADS_DESCRIPTION": "Ir a la vista de descargas",
"OPEN_GLOBAL_RECENT_LABEL": "Abrir vistos recientemente",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Abrir la página global de vistos recientemente",
"OPEN_SETTINGS_LABEL": "Abrir ajustes",
"OPEN_SETTINGS_DESCRIPTION": "Ir a los ajustes de la aplicación",
"OPEN_SOURCES_LABEL": "Gestionar fuentes",
"OPEN_SOURCES_DESCRIPTION": "Ir a la gestión de fuentes",
"OPEN_DASHBOARD_LABEL": "Abrir panel",
"OPEN_DASHBOARD_DESCRIPTION": "Ir al panel",
"ADD_PLAYLIST_LABEL": "Agregar nueva fuente",
"ADD_PLAYLIST_DESCRIPTION": "Abrir el diálogo para agregar lista de reproducción",
"ADD_PLAYLIST_M3U_LABEL": "Add M3U playlist",
"ADD_PLAYLIST_M3U_DESCRIPTION": "Open add dialog on the M3U file or URL tab",
"ADD_PLAYLIST_XTREAM_LABEL": "Add Xtream Codes playlist",
"ADD_PLAYLIST_XTREAM_DESCRIPTION": "Open add dialog on the Xtream Codes tab",
"ADD_PLAYLIST_STALKER_LABEL": "Add Stalker portal",
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab"
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab",
"REFRESH_PLAYLIST_LABEL": "Actualizar lista de reproducción",
"REFRESH_PLAYLIST_DESCRIPTION": "Volver a sincronizar los datos desde la fuente remota",
"PLAYLIST_INFO_LABEL": "Detalles de la lista",
"PLAYLIST_INFO_DESCRIPTION": "Mostrar la información de la lista actual",
"ACCOUNT_INFO_LABEL": "Información de la cuenta",
"ACCOUNT_INFO_DESCRIPTION": "Mostrar los detalles de la cuenta del servidor Xtream",
"OPEN_VIEW_DESCRIPTION": "Ir a {{view}}",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Borrar todos los elementos {{type}} visibles en esta vista",
"OPEN_MULTI_EPG_DESCRIPTION": "Abrir la vista EPG de varios canales",
"GO_TO_VOD_LABEL": "Películas",
"GO_TO_VOD_DESCRIPTION": "Ir a la sección de películas",
"GO_TO_LIVE_LABEL": "TV en vivo",
"GO_TO_LIVE_DESCRIPTION": "Ir a la sección de TV en vivo",
"GO_TO_SERIES_LABEL": "Series",
"GO_TO_SERIES_DESCRIPTION": "Ir a la sección de series",
"SWITCH_PLAYER_LABEL": "Cambiar reproductor a {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Establecer {{name}} como reproductor de vídeo activo",
"SWITCH_PLAYER_FEEDBACK": "Reproductor establecido a {{name}}"
},
"EXTERNAL_PLAYBACK_CLOSE": "Close player"
"XTREAM_REFRESH_TITLE": "Actualizando lista de reproducción",
"XTREAM_REFRESH_COLLECTING_DATA": "Conservando los datos de tu biblioteca...",
"XTREAM_REFRESH_DELETING_CONTENT": "Eliminando streams en caché...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Eliminando categorías en caché...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Guardamos favoritos, historial de reproducción, categorías ocultas y progreso para restaurarlos después de la sincronización.",
"XTREAM_REFRESH_PROGRESS": "Registros locales procesados: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Fuentes",
"SUBTITLE": "Filtrar y ordenar listas de reproducción",
"TYPE": "Tipo",
"ALL": "Todas",
"M3U_PLAYLISTS": "Listas M3U",
"XTREAM_PLAYLISTS": "Listas Xtream",
"STALKER_PLAYLISTS": "Listas Stalker",
"ALL_PLAYLISTS": "Todas las listas",
"PLAYLIST_COUNT_ONE": "1 lista de reproducción",
"PLAYLIST_COUNT_OTHER": "{{count}} listas de reproducción"
},
"CONTEXT": {
"CATEGORIES": "Categorías",
"MOVIE_CATEGORIES": "Categorías de películas",
"SERIES_CATEGORIES": "Categorías de series",
"LIVE_CATEGORIES": "Categorías en vivo",
"XTREAM_SYNCING_MOVIES": "Sincronizando categorías de películas…",
"XTREAM_SYNCING_SERIES": "Sincronizando categorías de series…",
"XTREAM_SYNCING_LIVE": "Sincronizando categorías en vivo…",
"XTREAM_SOURCE": "Fuente Xtream",
"STALKER_PORTAL": "Portal Stalker",
"MANAGE_CATEGORIES": "Gestionar categorías",
"LOAD_CATEGORIES_ERROR": "No se pueden cargar las categorías",
"LOAD_CATEGORIES_ERROR_HINT": "Reintenta o verifica la configuración del portal.",
"FILTER_BY_TYPE": "Filtrar por tipo",
"ITEM_COUNT_ONE": "1 elemento",
"ITEM_COUNT_OTHER": "{{count}} elementos",
"SEARCH": "Buscar",
"SEARCH_CATEGORIES": "Buscar categorías",
"RADIO_CATEGORIES": "Categorías de radio"
},
"COMMAND_PALETTE": {
"TITLE": "Paleta de comandos",
"SUBTITLE": "Acciones globales y contextuales",
"SEARCH_PLACEHOLDER": "Escribe para filtrar comandos",
"SEARCH_LABEL": "Filtrar comandos",
"EMPTY": "No se encontraron comandos",
"GROUP_GLOBAL": "Acciones globales",
"GROUP_PLAYLIST": "Esta lista de reproducción",
"GROUP_VIEW": "Vista actual",
"GROUP_SECTION": "Esta sección",
"GROUP_RECENT": "Usados recientemente"
},
"GLOBAL_FAVORITES": {
"TITLE": "Favoritos globales",
"SUBTITLE": "TV en vivo de todas las listas",
"SEARCH_PLACEHOLDER": "Buscar favoritos…",
"NO_RESULTS": "Sin resultados",
"NO_RESULTS_HINT": "Prueba con otro término de búsqueda",
"EMPTY_TITLE": "Aún no hay favoritos de TV en vivo",
"EMPTY_HINT": "Marca canales en vivo de listas M3U, Xtream o Stalker para verlos aquí.",
"EMPTY_PLAYER": "Selecciona un canal para empezar a ver",
"NO_PLAYLISTS_TITLE": "No favorites to show",
"NO_PLAYLISTS_BODY": "Add a playlist to start collecting favorites from live channels, movies, and series.",
"NO_ITEMS_TITLE": "No favorites yet",
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here."
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here.",
"UNKNOWN_CHANNEL": "Desconocido"
},
"GLOBAL_RECENT": {
"NO_PLAYLISTS_TITLE": "Nothing watched yet",
+554 -63
View File
@@ -1,5 +1,9 @@
{
"CANCEL": "Annuler",
"CLEAR": "Effacer",
"CLOSE": "Fermer",
"SHOW_MORE": "Plus",
"SHOW_LESS": "Moins",
"PLAYLISTS": "Listes de lecture",
"HOME": {
"TITLE": "IPTVnator",
@@ -21,6 +25,36 @@
"FEATURE_M3U": "Fichiers M3U/M3U8",
"FEATURE_XTREAM": "Xtream Codes",
"FEATURE_STALKER": "Portail Stalker",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"NO_RESULTS_TITLE": "Aucune liste trouvée",
"NO_RESULTS_DESCRIPTION": "Aucune liste ne correspond à votre recherche. Essayez d'ajuster vos termes de recherche ou effacez le filtre pour voir toutes les listes.",
"ADDED_VIA_URL": "Ajouté par URL. URL :",
@@ -32,7 +66,6 @@
"SHOW_DETAILS": "Afficher les détails de la liste de lecture",
"REMOVE": "Retirer la liste de lecture",
"SEARCH_PLAYLISTS": "Rechercher des listes par titre",
"AUTO_REFRESH_ENABLED": "Actualisation automatique activée",
"INFO_DIALOG": {
"PLAYLIST_DETAILS": "Détails de la liste de lecture",
"TITLE": "Titre",
@@ -61,16 +94,28 @@
"REMOVE_DIALOG": {
"TITLE": "Retirer la liste de lecture",
"MESSAGE": "Êtes-vous certain de vouloir complètement retirer cette liste de lecture ?",
"SUCCESS": "La liste de lecture a été supprimée avec succès"
"SUCCESS": "La liste de lecture a été supprimée avec succès",
"IN_PROGRESS": "Suppression de la liste de lecture…",
"DELETING_FAVORITES": "Suppression des favoris…",
"DELETING_RECENT": "Suppression des éléments récemment consultés…",
"DELETING_PROGRESS": "Suppression de la progression de lecture…",
"DELETING_DOWNLOADS": "Suppression des téléchargements…",
"DELETING_CONTENT": "Suppression du contenu en cache…",
"DELETING_CATEGORIES": "Suppression des catégories…",
"DELETING_PLAYLIST": "Suppression de l'enregistrement de la liste de lecture…"
},
"UPDATED": "Mis à jour",
"REFRESH": "Mettre à jour la liste de lecture",
"REFRESH_XTREAM": "Actualiser la liste Xtream depuis le serveur",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Actualiser la liste Xtream",
"MESSAGE": "Cela supprimera les catégories et flux mis en cache localement et réimportera tout depuis le serveur distant. Vos favoris et éléments récemment consultés seront conservés. Voulez-vous continuer ?",
"MESSAGE": "Cela supprimera le cache Xtream local et réimportera les catégories et les flux depuis le serveur distant. Les favoris, les éléments récemment consultés, la visibilité des catégories masquées et la progression de lecture seront restaurés après une réimportation réussie si le contenu correspondant existe toujours. Si le serveur est inaccessible, la restauration restera en attente jusqu'à une prochaine actualisation réussie. Continuer ?",
"STARTED": "Actualisation démarrée. Navigation vers la liste de lecture...",
"ERROR": "Échec de l'actualisation de la liste de lecture. Veuillez réessayer."
"ERROR": "Échec de l'actualisation de la liste de lecture. Veuillez réessayer.",
"IN_PROGRESS": "Actualisation de la liste de lecture…",
"COLLECTING_DATA": "Collecte des favoris et des éléments récemment consultés…",
"DELETING_CONTENT": "Suppression du contenu en cache…",
"DELETING_CATEGORIES": "Suppression des catégories en cache…"
},
"GLOBAL_FAVORITES": "Favoris globaux",
"GLOBAL_FAVORITES_DESCRIPTION": "Liste de lecture générée automatiquement avec les favoris agrégés de toutes les listes",
@@ -78,48 +123,26 @@
"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_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",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen."
"AUTO_REFRESH_ENABLED": "Actualisation automatique activée"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Glissez un fichier ici",
"OR": "ou",
"CHOOSE_PLAYLIST": "Choisir une liste de lecture",
"BROWSE": "Parcourir les fichiers",
"REJECTED": "Le fichier a été rejeté, format de fichier non pris en charge {{filename}}."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL de la liste de lecture (m3u, m3u8)",
"ADD_PLAYLIST": "Ajouter la liste de lecture",
"CORS_NOTE": "Remarque : Pour éviter les problèmes CORS, l'application utilise un service proxy CORS public. Si vous souhaitez télécharger une liste avec des données sensibles, il est plus sûr de l'importer depuis un fichier."
"CORS_NOTE": "Remarque : Pour éviter les problèmes CORS, l'application utilise un service proxy CORS public. Si vous souhaitez télécharger une liste avec des données sensibles, il est plus sûr de l'importer depuis un fichier.",
"ERROR_FETCH_FAILED": "Échec de la récupération de la liste de lecture. Veuillez vérifier l'URL et réessayer.",
"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."
},
"TEXT_IMPORT": {
"LABEL": "Insérer la liste m3u(8) sous forme de texte",
@@ -137,25 +160,68 @@
"STALKER_PORTAL": {
"TITLE": "Titre",
"MAC_ADDRESS": "Adresse Mac",
"SERIAL_NUMBER": "Numéro de série (optionnel)",
"SERIAL_NUMBER_HINT": "À utiliser si déjà enregistré auprès du fournisseur",
"DEVICE_ID_1": "ID d'appareil 1 (optionnel)",
"DEVICE_ID_2": "ID d'appareil 2 (optionnel)",
"DEVICE_ID_HINT": "64 caractères hexadécimaux — à utiliser si le fournisseur exige un ID d'appareil spécifique",
"SIGNATURE_1": "Signature 1 (optionnelle)",
"SIGNATURE_2": "Signature 2 (optionnelle)",
"SIGNATURE_HINT": "64 caractères hexadécimaux — à utiliser si le fournisseur exige des signatures de vérification d'appareil",
"SERVER_URL": "URL du serveur",
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com/c ou https://example.com/stalker_portal/c)",
"ADD": "Ajouter"
"ADD": "Ajouter",
"VALIDATING": "Validation du portail…"
},
"FILTER_BY_NAME": "Filtrer par nom",
"FILTER_BY_TYPE": "Filtrer par type",
"FILTER_AND_SORT": "Filtrer et trier",
"SORT_PLAYLISTS": "Trier les listes",
"SORT_BY": "Trier par",
"SORT_OPTIONS": {
"NAME_ASC": "Nom (A-Z)",
"NAME_DESC": "Nom (Z-A)",
"NEWEST": "Date d'ajout (Plus récent)",
"OLDEST": "Date d'ajout (Plus ancien)"
"OLDEST": "Date d'ajout (Plus ancien)",
"CUSTOM_ORDER": "Ordre personnalisé (Drag & Drop)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, URL, texte)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Importé sous forme de texte",
"PARSING_ERROR": "Erreur : échec de l'analyse, liste de lecture non valide :"
},
"SETTINGS": {
"TITLE": "Paramètres",
"GENERAL": "Général",
"GENERAL_SUBTITLE": "Modifier la configuration de l'application",
"PLAYBACK": "Lecture",
"EPG_SOURCES": "Sources EPG",
"EPG_SOURCES_DESCRIPTION": "Ajoutez une ou plusieurs URL comme sources EPG",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Préférer vos sources EPG pour les playlists Xtream",
"PREFER_UPLOADED_EPG_HINT": "Par défaut, les playlists Xtream utilisent le guide des programmes de votre fournisseur, et vos sources EPG ne comblent que les chaînes manquantes. Activez cette option pour utiliser d'abord votre EPG.",
"DATA": "Données",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"ABOUT": "À propos",
"NAV_GENERAL": "Général",
"NAV_PLAYBACK": "Lecture",
"NAV_EPG": "EPG",
"NAV_REMOTE": "Télécommande",
"NAV_DATA": "Données",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "À propos",
"APP_PREFERENCES": "Préférences de l'application",
"HERO_EYEBROW": "Paramètres de l'espace de travail",
"HERO_TITLE": "Façonnez l'expérience IPTVnator",
"HERO_DESCRIPTION": "Réglez la lecture, les guides, l'accès distant et la gestion des données depuis un seul endroit, sans perdre le style actuel de l'espace de travail.",
"SUBTITLE": "Modifiez la configuration de l'application",
"DESCRIPTION": "Changez les options de la liste ci-dessous et enregistrez vos modifications.",
"EPG_URL_LABEL": "URL du EPG (*.xml ou *.xml.gz)",
@@ -167,6 +233,12 @@
"EPG_STATUS_STALE": "À actualiser",
"EPG_STATUS_ERROR": "Échec",
"VIDEO_PLAYER_LABEL": "Lecteur vidéo",
"PLAYER_HTML5": "Lecteur vidéo HTML5",
"PLAYER_VIDEOJS": "Lecteur Video.js",
"PLAYER_ARTPLAYER": "ArtPlayer",
"PLAYER_EMBEDDED_MPV": "MPV intégré (expérimental, macOS)",
"PLAYER_MPV": "Lecteur MPV",
"PLAYER_VLC": "VLC",
"VIDEO_PLAYER_PLACEHOLDER": "Choisir une option",
"VERSION": "Version",
"SAVE_CHANGES": "Enregistrer les modifications",
@@ -178,20 +250,55 @@
"SETTINGS_SAVED": "La configuration a été enregistrée avec succès.",
"THEME": "Thème",
"SHOW_CAPTIONS": "Afficher les sous-titres",
"SHOW_DASHBOARD": "Afficher le tableau de bord",
"SHOW_DASHBOARD_DESCRIPTION": "Garde le tableau de bord disponible dans le rail de l'espace de travail. Lorsqu'il est désactivé, Sources devient la première page de l'espace de travail.",
"ADD_EPG_SOURCE": "Ajouter une source EPG",
"CLEAR_EPG_DATA": "Effacer les données EPG",
"CLEAR_EPG_DIALOG": {
"TITLE": "Effacer les données EPG",
"MESSAGE": "Êtes-vous sûr de vouloir supprimer toutes les données EPG (Guide électronique des programmes) de la base de données ? Cela supprimera tous les programmes mis en cache."
},
"EPG_DATA_CLEARED": "Les données EPG ont été effacées",
"EPG_DATA_CLEAR_FAILED": "Échec de l'effacement des données EPG. Veuillez réessayer.",
"CLEARING_EPG_DATA": "Effacement…",
"EPG_URL_ERROR": "La valeur n'est pas une URL valide",
"VIDEO_PLAYER_DESCRIPTION": "Sélectionnez le lecteur vidéo par défaut",
"STREAM_FORMAT_DESCRIPTION": "Sélectionnez le format de flux par défaut (xtream)",
"LANGUAGE_DESCRIPTION": "Sélectionnez la langue de l'application",
"THEME_DESCRIPTION": "Sélectionnez le thème visuel de l'application",
"COVER_SIZE": "Taille des affiches",
"COVER_SIZE_DESCRIPTION": "Ajustez la taille des cartes affiches dans les grilles et les rails. Les grandes tailles sont plus faciles à voir sur les téléviseurs ; les petites tailles affichent plus de contenu par ligne sur les grands écrans.",
"COVER_SIZE_SMALL": "Petite",
"COVER_SIZE_MEDIUM": "Moyenne",
"COVER_SIZE_LARGE": "Grande",
"STARTUP_BEHAVIOR": "Vue de démarrage",
"STARTUP_BEHAVIOR_DESCRIPTION": "Choisissez si IPTVnator ouvre la première vue disponible de l'espace de travail ou restaure votre dernière vue de section. Première vue signifie Tableau de bord lorsqu'il est activé, sinon Sources.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Première vue disponible",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurer la dernière vue",
"SHOW_CAPTIONS_DESCRIPTION": "Afficher ou masquer les sous-titres par défaut",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Afficher le pied de page de lecture en cours",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Garde la barre d'état du lecteur externe visible en bas de l'espace de travail sur ordinateur.",
"VERSION_DESCRIPTION": "Version actuelle de l'application",
"ABOUT_SUBTITLE": "Version, liens du projet et façons de soutenir IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV intégré",
"EMBEDDED_MPV_NOTICE_DESCRIPTION": "Les versions macOS peuvent inclure les bibliothèques libmpv et FFmpeg empaquetées pour le lecteur MPV intégré expérimental. Les utilisateurs n'ont pas besoin d'installer l'application MPV séparément ; les lecteurs externes MPV et VLC restent disponibles comme lecteurs de secours.",
"COMMUNITY": "Projet et communauté",
"COMMUNITY_DESCRIPTION": "Mettez une étoile au dépôt, lisez le blog ou rejoignez le groupe Telegram pour les actualités et l'aide.",
"LINK_BLOG": "Blog",
"LINK_GITHUB_DESCRIPTION": "Mettez une étoile au dépôt, ouvrez un ticket ou contribuez",
"LINK_BLOG_DESCRIPTION": "Notes de version, astuces et actualités du projet",
"LINK_TELEGRAM_DESCRIPTION": "Annonces, communauté et support",
"SUPPORT_TITLE": "Soutenir le projet",
"SUPPORT_DESCRIPTION": "IPTVnator est libre et open source, développé en toute transparence. S'il fait partie de votre quotidien, une petite contribution aide à maintenir le projet en vie et à façonner la suite — merci.",
"SUPPORT_SPONSORS_HINT": "Récurrent ou ponctuel",
"SUPPORT_KOFI_HINT": "Offrez-moi un café",
"EPG_NOTE": "Actuellement, l'EPG n'est disponible que dans la version Electron d'IPTVnator.",
"EPG_NOTE_URL_TEXT": "Vérifiez-le ici",
"IMPORT_EXPORT_DATA": "Importer/exporter des listes de lecture",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Exportez ou importez des listes au format JSON utilisé dans l'application.",
"IMPORT_DATA": "Importer",
"EXPORT_DATA": "Exporter",
"EXPORTING_DATA": "Exportation…",
"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.",
@@ -213,46 +320,116 @@
"MPV_PLAYER_PATH_LABEL": "Chemin mpv",
"MPV_PLAYER_PATH_DESCRIPTION": "Définissez le chemin d'accès au lecteur mpv dans votre système. Il utilisera le chemin fourni vers un binaire mpv au lieu de celui trouvé dans $PATH.",
"MPV_PLAYER_PATH": "Chemin du lecteur mpv",
"MPV_REUSE_INSTANCE_LABEL": "Réutiliser l'instance MPV",
"MPV_REUSE_INSTANCE_DESCRIPTION": "Lorsqu'elle est activée, la même fenêtre MPV sera réutilisée pour lire différents flux. Lorsqu'elle est désactivée, chaque flux s'ouvre dans une nouvelle fenêtre MPV.",
"VLC_PLAYER_PATH_LABEL": "Chemin VLC",
"VLC_PLAYER_PATH_DESCRIPTION": "Définissez le chemin d'accès au lecteur VLC dans votre système. Il utilisera le chemin fourni vers un binaire VLC au lieu du chemin par défaut.",
"VLC_PLAYER_PATH": "Chemin du lecteur VLC",
"VLC_REUSE_INSTANCE_LABEL": "Réutiliser l'instance VLC",
"VLC_REUSE_INSTANCE_DESCRIPTION": "Lorsqu'elle est activée, la même fenêtre VLC sera réutilisée pour lire différents flux. Lorsqu'elle est désactivée, chaque flux s'ouvre dans une nouvelle fenêtre VLC.",
"REMOTE_CONTROL": "Télécommande",
"REMOTE_CONTROL_DESCRIPTION": "Activer la télécommande de l'application.",
"REMOTE_CONTROL_PORT": "Port de télécommande",
"REMOTE_CONTROL_PORT_DESCRIPTION": "Port utilisé pour la télécommande de l'application. Le port 8765 est utilisé par défaut.",
"REMOTE_CONTROL_URL": "URL de la télécommande",
"REMOTE_CONTROL_URL_DESCRIPTION": "Accédez à la télécommande depuis un autre appareil sur le même réseau.",
"SHOW_QR_CODE": "Afficher le QR code",
"STATUS_ENABLED": "Activé",
"STATUS_DISABLED": "Désactivé",
"SOURCE_SINGULAR": "source",
"SOURCE_PLURAL": "sources",
"PORT_RANGE_LABEL": "Port (1-65535)",
"PORT_REQUIRED": "Le port est requis",
"PORT_MIN": "Le port doit être au moins 1",
"PORT_MAX": "Le port doit être au plus 65535",
"PORT_PATTERN": "Seuls les chiffres sont autorisés",
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset"
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Thème foncé",
"LIGHT_THEME": "Thème clair",
"SYSTEM_THEME": "Thème du système"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Masquer la liste des chaînes",
"SHOW_CHANNELS_LIST": "Afficher la liste des chaînes",
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Rechercher une chaîne",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture",
"ALL_CHANNELS": "Toutes les chaînes",
"GROUPS": "Groupes",
"MANAGE_GROUPS": "Gérer les groupes",
"MANAGE_GROUPS_HINT": "Utilisez la gestion des groupes pour réafficher les groupes.",
"NO_VISIBLE_GROUPS": "Aucun groupe visible",
"SEARCH_GROUPS": "Rechercher des groupes",
"UNGROUPED": "Non-groupé",
"UNNAMED_CHANNEL": "Chaîne sans nom",
"FAVORITES": "Favoris",
"NO_FAVORITES": "Aucun favoris",
"USE_STAR_TO_FAVORITE": "Veuillez utiliser l'icône 'étoile' pour ajouter une chaîne aux favoris.",
"REMOVE_FAVORITE": "Retirer des favoris",
"ADD_FAVORITE": "Ajouter aux favoris",
"SHOW_DETAILS": "Afficher les détails de la chaîne",
"FAVORITES_UPDATED": "Les favoris ont été mis à jour !",
"SELECT_CHANNEL_PLAYBACK": "Veuillez sélectionner une chaîne pour démarrer la lecture",
"SORT_BY": "Trier par",
"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_BY_NAME": "Trier par nom (A-Z)",
"LOAD_MORE": "Charger plus",
"REMAINING": "restantes",
"LOADING": "Chargement des chaînes…",
"LOADING_MORE": "Chargement…",
"NO_SEARCH_RESULTS": "Aucune chaîne trouvée",
"NO_CHANNELS_FOUND": "Aucune chaîne trouvée",
"NO_GROUPS_FOUND": "Aucun groupe trouvé",
"TRY_DIFFERENT_SEARCH": "Essayez un autre terme de recherche",
"DETAILS_DIALOG": {
"TITLE": "Détails de la chaîne",
"DESCRIPTION": "Métadonnées en lecture seule extraites du fichier de la liste de lecture.",
"SUMMARY": "Résumé",
"TVG": "Métadonnées TVG",
"CATCHUP": "Catchup",
"HTTP": "En-têtes HTTP",
"NAME": "Nom",
"CHANNEL_ID": "ID de la chaîne",
"STREAM_URL": "URL du flux",
"GROUP": "Groupe",
"RADIO": "Radio",
"CATCHUP_AVAILABLE": "Catchup disponible",
"WINDOW": "Fenêtre d'archivage",
"PLAYBACK_SUPPORTED": "Lecture prise en charge",
"EPG_PARAMS": "Paramètres EPG",
"TIMESHIFT": "Timeshift",
"TVG_ID": "TVG ID",
"TVG_NAME": "Nom TVG",
"TVG_URL": "URL TVG",
"TVG_LOGO": "Logo TVG",
"TVG_REC": "TVG rec",
"CATCHUP_TYPE": "Type de Catchup",
"CATCHUP_SOURCE": "Source du Catchup",
"CATCHUP_DAYS": "Jours de Catchup",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "Non fourni",
"NOT_AVAILABLE": "Non disponible",
"DAYS_ONE": "1 jour",
"DAYS_OTHER": "{{count}} jours"
},
"GROUP_MANAGEMENT": {
"TITLE": "Gérer les groupes",
"SELECTED": "Sélectionnés",
"SELECT_ALL": "Tout sélectionner",
"DESELECT_ALL": "Tout désélectionner",
"SEARCH_PLACEHOLDER": "Rechercher des groupes…",
"NO_RESULTS": "Aucun groupe correspondant trouvé",
"SAVE": "Enregistrer"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Ouvrir la liste des chaînes",
@@ -268,12 +445,16 @@
"LIVE_NOW": "En direct",
"LIVE_STREAM": "Diffusion en direct",
"TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Lecture des archives indisponible",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Ce fournisseur expose l'historique des programmes pour la chaîne, mais n'expose pas la lecture archive/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "L'EPG n'est pas disponible pour la date sélectionnée",
"NO_GUIDE_HINT": "Essayez une autre date ou actualisez la playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "L'EPG n'est pas disponible pour cette chaîne.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Veuillez ajouter/modifier l'URL de la liste EPG dans les réglages de l'application.",
"FETCH_EPG": "Récupération des données EPG...",
"ERROR": "L'EPG n'a pas pu être chargé.",
"DOWNLOAD_SUCCESS": "L'EPG a été téléchargé avec succès.",
"NO_PROGRAM_INFO": "Aucune information sur le programme disponible",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Détails du programme TV",
"TITLE": "Titre",
@@ -281,9 +462,23 @@
"PARENTAL_RATING_SYSTEM": "Système de classification du contenu télévisé",
"DESCRIPTION": "Description",
"LANGUAGE": "Langue",
"SHOW_PROGRAM_DETAILS": "Afficher les détails de ce programme"
"SHOW_PROGRAM_DETAILS": "Afficher les détails de ce programme",
"EPISODE": "Épisode",
"DURATION": "Durée"
},
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès"
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
"IMPORT_PROGRESS": "Importation EPG",
"CHANNELS": "chaînes",
"PROGRAMS": "programmes",
"LOADING": "Chargement",
"QUEUED": "En file d'attente",
"TOTAL_CHANNELS": "Total des chaînes",
"TOTAL_PROGRAMS": "Total des programmes",
"UP_TO_DATE": "L'EPG est à jour, aucune synchronisation nécessaire",
"RETRY": "Réessayer",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -330,6 +525,7 @@
"REFRESH": "Actualiser",
"XTREAM": {
"PLAY": "Lire",
"OPENING_PLAYER": "Ouverture du lecteur…",
"NAME": "Nom",
"RELEASE_DATE": "Date de sortie",
"GENRE": "Genre",
@@ -346,7 +542,59 @@
"YEAR": "Année",
"AGE": "Âge",
"YOUTUBE_TRAILER": "Bande-annonce YouTube",
"EPISODE": "Épisode"
"EPISODE": "Épisode",
"RESUME": "Reprendre",
"RESTART": "Recommencer",
"CONTINUE_WATCHING": "Reprendre la lecture",
"WATCHED": "Vu",
"IN_PROGRESS": "En cours",
"MARK_WATCHED": "Marquer comme vu",
"MARK_UNWATCHED": "Marquer comme non vu",
"EPISODES_WATCHED": "{{count}} sur {{total}} épisodes vus",
"DETAIL_FALLBACK": {
"STATUS": "Métadonnées du portail indisponibles",
"NOTE": "Les métadonnées étendues n'ont pas été fournies par ce portail.",
"STREAM_ID": "ID du flux",
"CATEGORY": "Catégorie",
"CONTAINER": "Format",
"SOURCE_TITLE": "Titre source"
},
"ACCOUNT_INFO": {
"TITLE": "Informations du compte",
"USER_INFO": "Informations utilisateur",
"STATUS": "Statut",
"USERNAME": "Nom d'utilisateur",
"ACTIVE_CONNECTIONS": "Connexions actives",
"CREATED": "Créé le",
"EXPIRES": "Expire le",
"TRIAL_ACCOUNT": "Compte d'essai",
"YES": "Oui",
"NO": "Non",
"ALLOWED_FORMATS": "Formats autorisés",
"SERVER_INFO": "Informations du serveur",
"LIVE_TV": "TV en direct",
"MOVIES": "Films",
"TV_SERIES": "Séries TV",
"URL": "URL",
"PROTOCOL": "Protocole",
"TIMEZONE": "Fuseau horaire",
"SERVER_TIME": "Heure du serveur",
"PORTS": "Ports",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
},
"CATEGORY_MANAGEMENT": {
"TITLE": "Gérer les catégories",
"LOADING": "Chargement des catégories…",
"SELECTED": "Sélectionnées",
"SELECT_ALL": "Tout sélectionner",
"DESELECT_ALL": "Tout désélectionner",
"SEARCH_PLACEHOLDER": "Rechercher des catégories…",
"NO_RESULTS": "Aucune catégorie correspondante trouvée",
"NO_CATEGORIES": "Aucune catégorie disponible",
"SAVE": "Enregistrer"
}
},
"PORTALS": {
"ALL_CATEGORIES": "Toutes les catégories",
@@ -355,6 +603,20 @@
"REMOVE_FROM_FAVORITES": "Retirer des favoris",
"REMOVED_FROM_FAVORITES": "Retiré des favoris",
"ADDED_TO_FAVORITES": "Ajouté aux favoris",
"FAVORITES": "Favoris",
"RECENTLY_VIEWED": "Récemment consultés",
"THIS_PLAYLIST": "Cette liste de lecture",
"ALL_PLAYLISTS": "Toutes les listes de lecture",
"LIVE_TV": "TV en direct",
"MOVIES": "Films",
"SERIES": "Séries",
"SEARCH": "Rechercher",
"NO_FAVORITES": "Aucun favori pour le moment",
"NO_RECENT_ITEMS": "Aucun élément récemment consulté",
"REMOVE_FROM_RECENT": "Retirer des éléments récemment consultés",
"BROWSE_ALL_MOVIES": "Parcourir tous les films",
"BROWSE_ALL_SERIES": "Parcourir toutes les séries",
"BROWSE_ALL_LIVE": "Parcourir toute la TV en direct",
"SIDEBAR": {
"MAIN": "Principal",
"MOVIES": "Films",
@@ -363,16 +625,25 @@
"LIBRARY": "Bibliothèque",
"SEARCH": "Rechercher",
"RECENT": "Récent",
"RECENTLY_ADDED": "Récemment ajoutés",
"FAVORITES": "Favoris",
"HOME": "Accueil",
"PLAYLIST_INFO": "Infos de la liste",
"ACCOUNT_INFO": "Infos du compte"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
"TITLE": "Importation annulée",
"DESCRIPTION": "L'importation de la liste de lecture a été annulée. Réessayez quand vous voudrez poursuivre le chargement de cette source."
},
"ACCOUNT_EXPIRED": {
"TITLE": "Compte expiré",
"DESCRIPTION": "Ce compte a expiré. Veuillez vérifier les identifiants dans les paramètres de la liste ou la supprimer."
},
"ACCOUNT_INACTIVE": {
"TITLE": "Compte inactif",
"DESCRIPTION": "Ce compte est inactif. Veuillez vérifier les identifiants dans les paramètres de la liste de lecture ou la supprimer."
},
"UNAUTHORIZED": {
"TITLE": "Non autorisé",
"DESCRIPTION": "Veuillez vérifier les identifiants dans les paramètres de la liste ou la supprimer."
@@ -381,6 +652,10 @@
"TITLE": "Portail non trouvé",
"DESCRIPTION": "Veuillez vérifier les identifiants et l'URL du portail dans les paramètres de la liste ou la supprimer."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "Portail indisponible",
"DESCRIPTION": "Le portail n'a pas pu être joint. Vérifiez l'URL du serveur ou réessayez quand la source sera de nouveau disponible."
},
"EMPTY_CATEGORY": {
"TITLE": "Catégorie vide",
"DESCRIPTION": "Cette catégorie n'a pas de contenu, veuillez sélectionner une autre catégorie ou modifier le type de contenu"
@@ -401,13 +676,24 @@
"TITLE": "Aucun résultat trouvé",
"NO_SEARCH_RESULTS": "Rien trouvé, essayez de modifier votre recherche"
},
"SEARCH_VIEW": {
"INITIAL_TITLE": "Rechercher du contenu",
"INITIAL_DESCRIPTION": "Saisissez au moins 3 caractères pour effectuer une recherche dans la TV en direct, les films et les séries.",
"RESULTS_FOUND": "{{count}} résultats trouvés",
"NO_RESULTS_FOR": "Aucun résultat trouvé pour \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Grouper les résultats par liste de lecture",
"XTREAM_ONLY_NOTE": "La recherche globale n'est actuellement disponible que pour les listes de lecture Xtream.",
"VISIBLE_CATEGORIES_ONLY": "Catégories visibles uniquement"
},
"STREAM_URL_COPIED": "URL du flux copiée dans le presse-papiers",
"COPY_STREAM_URL": "Copier l'URL du flux",
"SELECT_CATEGORY": "Sélectionner une catégorie",
"SEARCH_VIEW": {
"RESULTS_FOUND": "{{count}} résultats trouvés",
"NO_RESULTS_FOR": "Aucun résultat trouvé pour \"{{term}}\""
}
"PAGE": "Page",
"CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur",
"PLAYBACK_ERROR": "Échec de l'obtention de l'URL de lecture",
"LOADING_SEASONS": "Chargement des saisons…",
"NO_EPISODES": "Aucun épisode disponible",
"ALL_RADIO": "Toutes les radios"
},
"INFORMATION": "Information",
"DOWNLOADS": {
@@ -416,38 +702,135 @@
"PLAY_LOCAL": "Lire local",
"EMPTY": "Aucun téléchargement",
"EMPTY_HINT": "Téléchargez des films et épisodes pour les regarder hors ligne",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term",
"NOT_AVAILABLE": "Téléchargements non disponibles",
"DESKTOP_ONLY": "Cette fonctionnalité n'est disponible que dans l'application de bureau",
"NO_FOLDER": "Aucun dossier de téléchargement sélectionné",
"CHANGE_FOLDER": "Changer de dossier",
"CLEAR_COMPLETED": "Effacer terminés",
"CLEAR_COMPLETED_DIALOG": {
"TITLE": "Effacer les téléchargements terminés",
"MESSAGE": "Cela retire les entrées terminées, échouées et annulées de la vue actuelle des téléchargements. Cela ne supprime aucun fichier déjà téléchargé sur le disque."
},
"SOURCE_PLAYLIST_MISSING": "Ce téléchargement ne peut plus être ouvert dans la bibliothèque car sa liste de lecture source a été supprimée.",
"FILE_NOT_FOUND": "Ce fichier téléchargé n'est plus disponible sur le disque.",
"FILE_ACTION_ERROR": "L'action sur le fichier n'a pas pu être effectuée.",
"CANCEL": "Annuler",
"RETRY": "Réessayer",
"REMOVE": "Supprimer",
"PLAY": "Lire",
"REVEAL": "Afficher dans le dossier",
"COPY_URL": "Copier l'URL de téléchargement",
"URL_COPIED": "URL de téléchargement copiée dans le presse-papiers",
"URL_COPY_FAILED": "Échec de la copie de l'URL dans le presse-papiers",
"STATUS": {
"QUEUED": "En attente",
"DOWNLOADING": "En cours",
"COMPLETED": "Terminé",
"FAILED": "Échec",
"CANCELED": "Annulé"
},
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term"
}
},
"REMOTE_CONTROL": {
"TITLE": "Télécommande",
"SUBTITLE": "Contrôlez votre lecteur IPTV à distance",
"UP_CHANNEL": "Chaîne précédente",
"DOWN_CHANNEL": "Chaîne suivante",
"HEADER": "Télécommande IPTVNator",
"FOOTER": "Télécommande IPTVNator"
},
"WORKSPACE": {
"FAVORITES_ONLY": "Favoris uniquement",
"SORT_CUSTOM": "Ordre personnalisé",
"SORT_DATE_DESC": "Date d'ajout (plus récents en premier)",
"SORT_DATE_ASC": "Date d'ajout (plus anciens en premier)",
"SORT_NAME_ASC": "Nom A-Z",
"SORT_NAME_DESC": "Nom Z-A",
"SORT_LABEL": "Trier : ",
"DASHBOARD": {
"CONTINUE_WATCHING": "Reprendre la lecture",
"UNTITLED_SOURCE": "Source sans titre",
"GLOBAL_FAVORITES": "Favoris globaux",
"REMOVE_FROM_HISTORY": "Retirer de l'historique",
"RECENT_SOURCES": "Sources récemment utilisées",
"MANAGE_ALL": "Tout gérer",
"RECENTLY_WATCHED": "Récemment regardés",
"XTREAM_RECENTLY_ADDED": "Récemment ajoutés sur Xtream",
"NO_HISTORY": "Aucun élément trouvé pour ce filtre.",
"NO_HISTORY_HINT": "Votre historique de visionnage apparaîtra ici une fois que vous aurez commencé à lire du contenu depuis vos listes de lecture.",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U",
"STALKER_PORTAL": "Portail Stalker",
"PROVIDER": "Fournisseur",
"TYPE_LIVE": "Direct",
"TYPE_MOVIE": "Film",
"TYPE_SERIES": "Série",
"NOT_YET_SYNCED": "Pas encore synchronisé"
},
"SHELL": {
"BRAND_ALT": "IPTVnator",
"RAIL_DASHBOARD": "Tableau de bord",
"OPEN_DASHBOARD": "Ouvrir le tableau de bord",
"RAIL_SOURCES": "Sources",
"OPEN_SOURCES": "Ouvrir les sources",
"RAIL_GLOBAL_FAVORITES": "Favoris",
"RAIL_GLOBAL_RECENT": "Récemment consultés",
"RAIL_SETTINGS": "Paramètres",
"OPEN_SETTINGS": "Ouvrir les paramètres",
"RAIL_LIBRARY": "Bibliothèque",
"RAIL_MOVIES": "Films",
"RAIL_LIVE": "TV en direct",
"RAIL_SERIES": "Séries",
"RAIL_RECENTLY_ADDED": "Récemment ajoutés",
"RAIL_SEARCH": "Recherche avancée",
"RAIL_RECENT": "Récemment consultés",
"RAIL_FAVORITES": "Favoris",
"RAIL_DOWNLOADS": "Téléchargements",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"RAIL_ALL_CHANNELS": "Toutes les chaînes",
"RAIL_GROUPS": "Groupes",
"RAIL_CONTEXT_ACTIONS": "Actions de section",
"SEARCH_LABEL": "Rechercher",
"SEARCH_SOURCES_PLACEHOLDER": "Rechercher des sources (toutes les listes)…",
"SEARCH_PLAYLIST_PLACEHOLDER": "Rechercher dans cette liste de lecture…",
"SEARCH_SECTION_PLACEHOLDER": "Rechercher dans cette section…",
"FILTER_SECTION_PLACEHOLDER": "Filtrer cette section…",
"SEARCH_STATUS_LOADED_ONLY": "Chaînes chargées uniquement",
"COMMAND_PALETTE_SHORTCUT": "Palette de commandes (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "Ouvrir la palette de commandes",
"ADD_PLAYLIST_ALL": "Ajouter une liste de lecture",
"ADD_PLAYLIST": "Ajouter une liste de lecture",
"OPEN_DOWNLOADS": "Ouvrir les téléchargements",
"OPEN_CONTEXT_ACTIONS": "Ouvrir les actions de section",
"NO_SECTION_ACTIONS": "Aucune action pour cette section",
"PLAYLIST_INFO": "Infos de la liste de lecture",
"ACCOUNT_INFO": "Infos du compte",
"REFRESH_PLAYLIST": "Actualiser la liste de lecture",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "Effacer les téléchargements terminés (toutes les listes)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Effacer les téléchargements terminés pour toutes les listes de lecture",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Effacer les téléchargements terminés (cette liste)",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Effacer les téléchargements terminés pour cette liste de lecture",
"CLEAR_RECENTLY_VIEWED_SECTION": "Effacer les éléments récemment consultés",
"CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Effacer les éléments récemment consultés pour cette section",
"CLEAR_RECENTLY_VIEWED_TYPE": "Effacer les {{type}} récemment consultés",
"CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Effacer les {{type}} récemment consultés ?",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Tous les éléments {{type}} de cette liste de lecture seront retirés de votre historique récemment consulté. Cette action est irréversible.",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL": "Tous les éléments {{type}} de toutes les listes de lecture seront retirés de votre historique récemment consulté. Cette action est irréversible.",
"CLEAR_FAVORITES_TYPE": "Effacer les favoris {{type}}",
"CLEAR_FAVORITES_DIALOG_TITLE": "Retirer tous les favoris {{type}} ?",
"CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST": "Tous les favoris {{type}} de cette liste de lecture seront retirés. Cette action est irréversible.",
"CLEAR_FAVORITES_DIALOG_MESSAGE_ALL": "Tous les favoris {{type}} de toutes les listes de lecture seront retirés. Cette action est irréversible.",
"XTREAM_CODE": "Xtream Code",
"STALKER_PORTAL": "Portail Stalker",
"CHANNELS_COUNT": "{{count}} chaînes",
"NO_SOURCES_AVAILABLE": "Aucune source disponible",
"ONE_SOURCE_AVAILABLE": "1 source disponible",
"SOURCES_AVAILABLE": "{{count}} sources disponibles",
"GO_TO_DASHBOARD": "Back to dashboard",
"GO_TO_SOURCES": "Go to Sources",
"DROP_OVERLAY_TITLE": "Drop to add playlist",
@@ -457,21 +840,129 @@
"DROP_OVERLAY_REJECTED_EMPTY": "That file is empty",
"DROP_OVERLAY_REJECTED_READ_ERROR": "Could not read that file",
"DROP_IMPORT_SUCCESS": "Importing \"{{title}}\"…",
"XTREAM_IMPORT_TITLE": "Synchronisation de la liste de lecture",
"XTREAM_IMPORT_REMOTE_BADGE": "Source distante",
"XTREAM_IMPORT_LOCAL_BADGE": "Bibliothèque locale",
"XTREAM_IMPORT_DETAIL_REMOTE": "En attente de la réponse du fournisseur.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Préparation d'une bascule plus rapide entre listes de lecture pour la prochaine visite.",
"XTREAM_IMPORT_PREPARING": "Préparation de la bibliothèque locale…",
"XTREAM_IMPORT_LOADING": "Récupération des données de la liste depuis la source…",
"XTREAM_IMPORT_SAVING": "Enregistrement des données de la liste localement…",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restauration des favoris depuis la bibliothèque locale…",
"XTREAM_IMPORT_RESTORING_RECENT": "Restauration des éléments récemment consultés depuis la bibliothèque locale…",
"XTREAM_IMPORT_PROGRESS": "{{type}} importé : {{current}} / {{total}}",
"XTREAM_IMPORT_CANCEL": "Arrêter la synchronisation",
"XTREAM_IMPORT_CANCELLING": "Arrêt de la synchronisation…",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Rechercher dans toutes les listes de lecture Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "Ouvrir la superposition de recherche globale",
"PLAYLIST_SEARCH_LABEL": "Recherche avancée dans cette liste de lecture",
"PLAYLIST_SEARCH_DESCRIPTION": "Ouvrir la recherche avancée de la liste de lecture",
"OPEN_GLOBAL_FAVORITES_LABEL": "Ouvrir les favoris globaux",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "Naviguer vers les favoris agrégés",
"OPEN_DOWNLOADS_LABEL": "Ouvrir les téléchargements",
"OPEN_DOWNLOADS_DESCRIPTION": "Naviguer vers la vue des téléchargements",
"OPEN_GLOBAL_RECENT_LABEL": "Ouvrir les éléments récemment consultés",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Ouvrir la page globale des éléments récemment consultés",
"OPEN_SETTINGS_LABEL": "Ouvrir les paramètres",
"OPEN_SETTINGS_DESCRIPTION": "Naviguer vers les paramètres de l'application",
"OPEN_SOURCES_LABEL": "Gérer les sources",
"OPEN_SOURCES_DESCRIPTION": "Naviguer vers la gestion des sources",
"OPEN_DASHBOARD_LABEL": "Ouvrir le tableau de bord",
"OPEN_DASHBOARD_DESCRIPTION": "Naviguer vers le tableau de bord",
"ADD_PLAYLIST_LABEL": "Ajouter une nouvelle source",
"ADD_PLAYLIST_DESCRIPTION": "Ouvrir la fenêtre d'ajout de liste de lecture",
"ADD_PLAYLIST_M3U_LABEL": "Add M3U playlist",
"ADD_PLAYLIST_M3U_DESCRIPTION": "Open add dialog on the M3U file or URL tab",
"ADD_PLAYLIST_XTREAM_LABEL": "Add Xtream Codes playlist",
"ADD_PLAYLIST_XTREAM_DESCRIPTION": "Open add dialog on the Xtream Codes tab",
"ADD_PLAYLIST_STALKER_LABEL": "Add Stalker portal",
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab"
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab",
"REFRESH_PLAYLIST_LABEL": "Actualiser la liste de lecture",
"REFRESH_PLAYLIST_DESCRIPTION": "Resynchroniser les données depuis la source distante",
"PLAYLIST_INFO_LABEL": "Détails de la liste de lecture",
"PLAYLIST_INFO_DESCRIPTION": "Afficher les informations de la liste de lecture en cours",
"ACCOUNT_INFO_LABEL": "Infos du compte",
"ACCOUNT_INFO_DESCRIPTION": "Afficher les détails du compte du serveur Xtream",
"OPEN_VIEW_DESCRIPTION": "Naviguer vers {{view}}",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Effacer tous les éléments {{type}} visibles dans cette vue",
"OPEN_MULTI_EPG_DESCRIPTION": "Ouvrir la vue EPG multi-chaînes",
"GO_TO_VOD_LABEL": "Films",
"GO_TO_VOD_DESCRIPTION": "Aller à la section films",
"GO_TO_LIVE_LABEL": "TV en direct",
"GO_TO_LIVE_DESCRIPTION": "Aller à la section TV en direct",
"GO_TO_SERIES_LABEL": "Séries",
"GO_TO_SERIES_DESCRIPTION": "Aller à la section séries",
"SWITCH_PLAYER_LABEL": "Basculer le lecteur sur {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Définir {{name}} comme lecteur vidéo actif",
"SWITCH_PLAYER_FEEDBACK": "Lecteur défini sur {{name}}"
},
"EXTERNAL_PLAYBACK_CLOSE": "Close player"
"XTREAM_REFRESH_TITLE": "Actualisation de la playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Préservation des données de votre bibliothèque...",
"XTREAM_REFRESH_DELETING_CONTENT": "Suppression des flux en cache...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Suppression des catégories en cache...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Les favoris, l’historique de lecture, les catégories masquées et la progression sont préparés pour être restaurés après la synchronisation.",
"XTREAM_REFRESH_PROGRESS": "Enregistrements locaux traités : {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Sources",
"SUBTITLE": "Filtrer et trier les listes de lecture",
"TYPE": "Type",
"ALL": "Toutes",
"M3U_PLAYLISTS": "Listes de lecture M3U",
"XTREAM_PLAYLISTS": "Listes de lecture Xtream",
"STALKER_PLAYLISTS": "Listes de lecture Stalker",
"ALL_PLAYLISTS": "Toutes les listes de lecture",
"PLAYLIST_COUNT_ONE": "1 liste de lecture",
"PLAYLIST_COUNT_OTHER": "{{count}} listes de lecture"
},
"CONTEXT": {
"CATEGORIES": "Catégories",
"MOVIE_CATEGORIES": "Catégories de films",
"SERIES_CATEGORIES": "Catégories de séries",
"LIVE_CATEGORIES": "Catégories en direct",
"XTREAM_SYNCING_MOVIES": "Synchronisation des catégories de films…",
"XTREAM_SYNCING_SERIES": "Synchronisation des catégories de séries…",
"XTREAM_SYNCING_LIVE": "Synchronisation des catégories en direct…",
"XTREAM_SOURCE": "Source Xtream",
"STALKER_PORTAL": "Portail Stalker",
"MANAGE_CATEGORIES": "Gérer les catégories",
"LOAD_CATEGORIES_ERROR": "Impossible de charger les catégories",
"LOAD_CATEGORIES_ERROR_HINT": "Veuillez réessayer ou vérifier les paramètres du portail.",
"FILTER_BY_TYPE": "Filtrer par type",
"ITEM_COUNT_ONE": "1 élément",
"ITEM_COUNT_OTHER": "{{count}} éléments",
"SEARCH": "Rechercher",
"SEARCH_CATEGORIES": "Rechercher des catégories",
"RADIO_CATEGORIES": "Catégories radio"
},
"COMMAND_PALETTE": {
"TITLE": "Palette de commandes",
"SUBTITLE": "Actions globales et contextuelles",
"SEARCH_PLACEHOLDER": "Saisir pour filtrer les commandes",
"SEARCH_LABEL": "Filtrer les commandes",
"EMPTY": "Aucune commande trouvée",
"GROUP_GLOBAL": "Actions globales",
"GROUP_PLAYLIST": "Cette liste de lecture",
"GROUP_VIEW": "Vue actuelle",
"GROUP_SECTION": "Cette section",
"GROUP_RECENT": "Récemment utilisées"
},
"GLOBAL_FAVORITES": {
"TITLE": "Favoris globaux",
"SUBTITLE": "TV en direct de toutes les listes de lecture",
"SEARCH_PLACEHOLDER": "Rechercher dans les favoris…",
"NO_RESULTS": "Aucun résultat",
"NO_RESULTS_HINT": "Essayez un autre terme de recherche",
"EMPTY_TITLE": "Aucun favori TV en direct pour le moment",
"EMPTY_HINT": "Mettez en favori des chaînes en direct depuis vos listes M3U, Xtream ou Stalker pour les voir ici.",
"EMPTY_PLAYER": "Sélectionnez une chaîne pour commencer à regarder",
"NO_PLAYLISTS_TITLE": "No favorites to show",
"NO_PLAYLISTS_BODY": "Add a playlist to start collecting favorites from live channels, movies, and series.",
"NO_ITEMS_TITLE": "No favorites yet",
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here."
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here.",
"UNKNOWN_CHANNEL": "Inconnue"
},
"GLOBAL_RECENT": {
"NO_PLAYLISTS_TITLE": "Nothing watched yet",
+575 -63
View File
@@ -1,5 +1,9 @@
{
"CANCEL": "Annulla",
"CLEAR": "Cancella",
"CLOSE": "Chiudi",
"SHOW_MORE": "Altro",
"SHOW_LESS": "Meno",
"PLAYLISTS": "Playlist",
"HOME": {
"TITLE": "IPTVnator",
@@ -15,6 +19,44 @@
"PLAYLISTS": {
"NO_PLAYLISTS": "Nessuna playlist è stata aggiunta",
"ADD_FIRST": "Aggiungi la tua prima playlist dall'altra scheda",
"WELCOME_TITLE": "Benvenuto in IPTVnator",
"WELCOME_DESCRIPTION": "Inizia il tuo viaggio nello streaming aggiungendo la tua prima playlist. Connettiti al tuo servizio IPTV preferito e goditi TV in diretta, film e serie.",
"ADD_YOUR_FIRST": "Aggiungi la tua prima playlist",
"FEATURE_M3U": "File M3U/M3U8",
"FEATURE_XTREAM": "Codici Xtream",
"FEATURE_STALKER": "Portale Stalker",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"NO_RESULTS_TITLE": "Nessuna playlist trovata",
"NO_RESULTS_DESCRIPTION": "Nessuna playlist corrisponde alla ricerca. Modifica i termini di ricerca o cancella il filtro per vedere tutte le playlist.",
"ADDED_VIA_URL": "Aggiunto tramite caricamento URL. URL:",
"ADDED_VIA_FILE": "Aggiunto tramite caricamento file",
"XTREAM_PLAYLIST": "Playlist Xtream",
@@ -43,60 +85,64 @@
"SERVER_URL": "URL server",
"USERNAME": "Utente",
"PASSWORD": "Password",
"MAC_ADDRESS": "Indirizzo MAC"
"MAC_ADDRESS": "Indirizzo MAC",
"PLAYLIST_EXPORT_SUCCESS": "Playlist esportata correttamente.",
"PLAYLIST_EXPORT_ERROR": "Errore: esportazione della playlist fallita.",
"COPY_URL": "Copia URL negli appunti",
"URL_COPIED": "URL copiato negli appunti"
},
"REMOVE_DIALOG": {
"TITLE": "Rimuovi playlist",
"MESSAGE": "Sei sicuro di voler eliminare completamente questa playlist?"
"MESSAGE": "Sei sicuro di voler eliminare completamente questa playlist?",
"SUCCESS": "Playlist rimossa correttamente",
"IN_PROGRESS": "Rimozione playlist...",
"DELETING_FAVORITES": "Rimozione preferiti...",
"DELETING_RECENT": "Rimozione visti di recente...",
"DELETING_PROGRESS": "Rimozione progressi di riproduzione...",
"DELETING_DOWNLOADS": "Rimozione download...",
"DELETING_CONTENT": "Rimozione contenuti memorizzati...",
"DELETING_CATEGORIES": "Rimozione categorie...",
"DELETING_PLAYLIST": "Rimozione record playlist..."
},
"UPDATED": "Aggiornata",
"REFRESH": "Aggiorna playlist",
"REFRESH_XTREAM": "Aggiorna playlist Xtream dal server remoto",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Aggiorna playlist Xtream",
"MESSAGE": "Verrà eliminata la cache locale di Xtream e saranno reimportate categorie e stream dal server remoto. Preferiti, elementi visti di recente, visibilità delle categorie nascoste e progressi di riproduzione vengono ripristinati dopo una reimportazione riuscita se i contenuti corrispondenti esistono ancora. Se il server non è raggiungibile, il ripristino resta in sospeso fino a un successivo aggiornamento riuscito. Continuare?",
"STARTED": "Aggiornamento avviato. Navigazione alla playlist...",
"ERROR": "Aggiornamento della playlist non riuscito. Riprova.",
"IN_PROGRESS": "Aggiornamento playlist...",
"COLLECTING_DATA": "Raccolta preferiti e visti di recente...",
"DELETING_CONTENT": "Rimozione contenuti memorizzati...",
"DELETING_CATEGORIES": "Rimozione categorie memorizzate..."
},
"GLOBAL_FAVORITES": "Preferiti globali",
"GLOBAL_FAVORITES_DESCRIPTION": "Playlist generata automaticamente con preferiti aggregati da tutte le playlist",
"MY_PLAYLISTS": "Le mie playlist",
"MY_PLAYLISTS_SUBTITLE": "tutte le playlist disponibili",
"PLAYLIST_UPDATE_SUCCESS": "La playlist è stata correttamente aggiornata.",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen."
"PLAYLIST_UPDATE_ERROR": "Errore durante l'aggiornamento dei dettagli della 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",
"AUTO_REFRESH_ENABLED": "Aggiornamento automatico attivo"
},
"FILE_UPLOAD": {
"DRAG_DROP": "Trascina e rilascia qui i file",
"OR": "o",
"CHOOSE_PLAYLIST": "scegli una playlist",
"BROWSE": "Sfoglia file",
"REJECTED": "Il file è stato rifiutato, formato di file non supportato {{filename}}."
},
"URL_UPLOAD": {
"PLAYLIST_URL": "URL playlist (m3u, m3u8)",
"ADD_PLAYLIST": "Aggiungi playlist",
"CORS_NOTE": "Nota: per evitare problemi CORS a questo punto, l'applicazione usa un servizio proxy CORS pubblico. Se vuoi caricare una playlist con dati sensibili, è meglio importarla come file nella scheda adiacente."
"CORS_NOTE": "Nota: per evitare problemi CORS a questo punto, l'applicazione usa un servizio proxy CORS pubblico. Se vuoi caricare una playlist con dati sensibili, è meglio importarla come file nella scheda adiacente.",
"ERROR_FETCH_FAILED": "Impossibile recuperare la playlist. Controlla l'URL e riprova.",
"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."
},
"TEXT_IMPORT": {
"LABEL": "Inserisci playlist m3u(8) come testo",
@@ -114,19 +160,35 @@
"STALKER_PORTAL": {
"TITLE": "Titolo",
"MAC_ADDRESS": "Indirizzo MAC",
"SERIAL_NUMBER": "Numero di serie (opzionale)",
"SERIAL_NUMBER_HINT": "Usa se già registrato presso il fornitore",
"DEVICE_ID_1": "ID dispositivo 1 (opzionale)",
"DEVICE_ID_2": "ID dispositivo 2 (opzionale)",
"DEVICE_ID_HINT": "64 caratteri esadecimali — usa se il fornitore richiede un ID dispositivo specifico",
"SIGNATURE_1": "Firma 1 (opzionale)",
"SIGNATURE_2": "Firma 2 (opzionale)",
"SIGNATURE_HINT": "64 caratteri esadecimali — usa se il fornitore richiede firme di verifica del dispositivo",
"SERVER_URL": "URL server",
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)",
"ADD": "Aggiungi"
"ADD": "Aggiungi",
"VALIDATING": "Validazione del portale..."
},
"FILTER_BY_NAME": "Filtra per nome",
"FILTER_BY_TYPE": "Filtra per tipo",
"FILTER_AND_SORT": "Filtra e ordina",
"SORT_PLAYLISTS": "Ordina playlist",
"SORT_BY": "Ordina per",
"SORT_OPTIONS": {
"NAME_ASC": "Nome (A-Z)",
"NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data aggiunta (prima più recenti)",
"OLDEST": "Data aggiunta (prima più vecchi)"
"OLDEST": "Data aggiunta (prima più vecchi)",
"CUSTOM_ORDER": "Ordine personalizzato (Drag & Drop)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (locale, URL, testo)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "Importato come testo",
"PARSING_ERROR": "Errore: analisi fallita, non è una playlist valida:"
@@ -138,13 +200,23 @@
"PLAYBACK": "Riproduzione",
"EPG_SOURCES": "Sorgenti EPG",
"EPG_SOURCES_DESCRIPTION": "Aggiungi uno o piu URL come sorgenti EPG",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Preferisci le tue sorgenti EPG per le playlist Xtream",
"PREFER_UPLOADED_EPG_HINT": "Per impostazione predefinita, le playlist Xtream usano la guida programmi del tuo provider e le tue sorgenti EPG riempiono solo i canali mancanti. Attiva questa opzione per usare prima il tuo EPG.",
"DATA": "Dati",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"ABOUT": "Info",
"NAV_GENERAL": "Generale",
"NAV_PLAYBACK": "Riproduzione",
"NAV_EPG": "EPG",
"NAV_REMOTE": "Remote",
"NAV_DATA": "Dati",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Info",
"APP_PREFERENCES": "Preferenze app",
"HERO_EYEBROW": "Impostazioni area di lavoro",
@@ -164,6 +236,7 @@
"PLAYER_HTML5": "Lettore HTML5",
"PLAYER_VIDEOJS": "Lettore Video.js",
"PLAYER_ARTPLAYER": "ArtPlayer",
"PLAYER_EMBEDDED_MPV": "MPV integrato (sperimentale, macOS)",
"PLAYER_MPV": "Lettore MPV",
"PLAYER_VLC": "VLC",
"VIDEO_PLAYER_PLACEHOLDER": "Seleziona una opzione",
@@ -177,6 +250,8 @@
"SETTINGS_SAVED": "La configurazione è stata salvata.",
"THEME": "Tema visuale",
"SHOW_CAPTIONS": "Visualizza sottotitoli",
"SHOW_DASHBOARD": "Mostra dashboard",
"SHOW_DASHBOARD_DESCRIPTION": "Mantieni la dashboard disponibile nella barra dell'area di lavoro. Se disattivato, Sorgenti diventa la prima pagina dell'area di lavoro.",
"ADD_EPG_SOURCE": "Aggiungi sorgente EPG",
"CLEAR_EPG_DATA": "Cancella dati EPG",
"CLEAR_EPG_DIALOG": {
@@ -191,14 +266,39 @@
"STREAM_FORMAT_DESCRIPTION": "Seleziona il formato di streaming predefinito (xtream)",
"LANGUAGE_DESCRIPTION": "Seleziona la lingua dell'applicazione",
"THEME_DESCRIPTION": "Seleziona il tema visivo dell'applicazione",
"COVER_SIZE": "Dimensione copertine",
"COVER_SIZE_DESCRIPTION": "Regola la dimensione delle schede poster nelle griglie e nei carousel. Le dimensioni più grandi sono più leggibili sulle TV; quelle più piccole mostrano più contenuti per riga sui monitor grandi.",
"COVER_SIZE_SMALL": "Piccola",
"COVER_SIZE_MEDIUM": "Media",
"COVER_SIZE_LARGE": "Grande",
"STARTUP_BEHAVIOR": "Vista all'avvio",
"STARTUP_BEHAVIOR_DESCRIPTION": "Scegli se IPTVnator apre la prima vista dell'area di lavoro disponibile o ripristina l'ultima vista a livello di sezione. La prima vista è la dashboard se attiva, altrimenti Sorgenti.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Prima vista disponibile",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Ripristina ultima vista",
"SHOW_CAPTIONS_DESCRIPTION": "Visualizza/nascondi i sottotitoli per impostazione predefinita",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostra footer In riproduzione",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantieni visibile la barra di stato del lettore esterno in fondo all'area di lavoro su desktop.",
"VERSION_DESCRIPTION": "Versione attuale dell'applicazione",
"ABOUT_SUBTITLE": "Versione, link al progetto e modi per supportare IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrato",
"EMBEDDED_MPV_NOTICE_DESCRIPTION": "Le build per macOS possono includere le librerie libmpv e FFmpeg in bundle per il lettore MPV integrato sperimentale. Gli utenti non devono installare l'app MPV separatamente; MPV e VLC esterni restano disponibili come lettori di riserva.",
"COMMUNITY": "Progetto e community",
"COMMUNITY_DESCRIPTION": "Metti una stella al repository, leggi il blog o unisciti al gruppo Telegram per aggiornamenti e supporto.",
"LINK_BLOG": "Blog",
"LINK_GITHUB_DESCRIPTION": "Metti una stella al repo, apri un issue o contribuisci",
"LINK_BLOG_DESCRIPTION": "Note di rilascio, suggerimenti e aggiornamenti del progetto",
"LINK_TELEGRAM_DESCRIPTION": "Annunci, community e supporto",
"SUPPORT_TITLE": "Supporta il progetto",
"SUPPORT_DESCRIPTION": "IPTVnator è gratuito e open source, sviluppato in modo aperto. Se fa parte della tua routine quotidiana, un piccolo contributo aiuta a mantenere vivo il progetto e a definire i prossimi sviluppi — grazie.",
"SUPPORT_SPONSORS_HINT": "Ricorrente o una tantum",
"SUPPORT_KOFI_HINT": "Offrimi un caffè",
"EPG_NOTE": "Attualmente EPG è disponibile solo nella versione dell'applicazione electron.",
"EPG_NOTE_URL_TEXT": "Maggiori info qui",
"IMPORT_EXPORT_DATA": "Importa/esporta playlist",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Esporta/importa playlist nel formato JSON usato all'interno dell'applicazione.",
"IMPORT_DATA": "Importa",
"EXPORT_DATA": "Esporta",
"EXPORTING_DATA": "Esportazione...",
"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.",
@@ -225,6 +325,8 @@
"VLC_PLAYER_PATH_LABEL": "Percorso VLC",
"VLC_PLAYER_PATH_DESCRIPTION": "Imposta il percorso del lettore VLC nel sistema. Verrà usato il percorso fornito per il file exe VLC invece del percorso predefinito per il sistema.",
"VLC_PLAYER_PATH": "Percorso lettore VLC",
"VLC_REUSE_INSTANCE_LABEL": "Riutilizza istanza VLC",
"VLC_REUSE_INSTANCE_DESCRIPTION": "Quando attivo, la stessa finestra VLC viene riutilizzata per riprodurre stream diversi. Quando disattivo, ogni stream viene aperto in una nuova finestra VLC.",
"REMOTE_CONTROL": "Telecomando",
"REMOTE_CONTROL_DESCRIPTION": "Abilita controllo remoto nell'applicazione.",
"REMOTE_CONTROL_PORT": "Porta controllo remoto",
@@ -243,43 +345,98 @@
"PORT_PATTERN": "Sono consentite solo cifre",
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset"
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Tema scuro",
"LIGHT_THEME": "Tema chiaro",
"SYSTEM_THEME": "Tema di sistema"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Nascondi elenco canali",
"SHOW_CHANNELS_LIST": "Mostra elenco canali",
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Cerca canale",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist",
"ALL_CHANNELS": "Tutti i canali",
"GROUPS": "Gruppi",
"MANAGE_GROUPS": "Gestisci gruppi",
"MANAGE_GROUPS_HINT": "Usa Gestisci gruppi per mostrare di nuovo i gruppi.",
"NO_VISIBLE_GROUPS": "Nessun gruppo visibile",
"SEARCH_GROUPS": "Cerca gruppi",
"UNGROUPED": "Non raggruppati",
"UNNAMED_CHANNEL": "Canale senza nome",
"FAVORITES": "Preferiti",
"NO_FAVORITES": "Ancora nessun preferito",
"USE_STAR_TO_FAVORITE": "Usa l'icona 'stella' per aggiungere canali ai preferiti.",
"REMOVE_FAVORITE": "Rimuovi dai preferiti",
"ADD_FAVORITE": "Aggiungi ai preferiti",
"SHOW_DETAILS": "Mostra dettagli canale",
"FAVORITES_UPDATED": "Aggiornamento preferiti completato!",
"SELECT_CHANNEL_PLAYBACK": "Seleziona un canale per avviare la riproduzione",
"SORT_BY": "Ordina per",
"SORT_BY_DATE": "Data inserimento (prima più recenti)",
"SORT_BY_RATING": "Valutazione",
"SORT_BY_NAME": "Nome (A-Z)"
"SORT_BY_NAME": "Nome (A-Z)",
"LOAD_MORE": "Carica altri",
"REMAINING": "rimanenti",
"LOADING": "Caricamento canali...",
"LOADING_MORE": "Caricamento altri...",
"NO_SEARCH_RESULTS": "Nessun canale trovato",
"NO_CHANNELS_FOUND": "Nessun canale trovato",
"NO_GROUPS_FOUND": "Nessun gruppo trovato",
"TRY_DIFFERENT_SEARCH": "Prova con un altro termine di ricerca",
"DETAILS_DIALOG": {
"TITLE": "Dettagli canale",
"DESCRIPTION": "Metadati di sola lettura analizzati dal file della playlist.",
"SUMMARY": "Riepilogo",
"TVG": "Metadati TVG",
"CATCHUP": "Catchup",
"HTTP": "Header HTTP",
"NAME": "Nome",
"CHANNEL_ID": "ID canale",
"STREAM_URL": "URL stream",
"GROUP": "Gruppo",
"RADIO": "Radio",
"CATCHUP_AVAILABLE": "Catchup disponibile",
"WINDOW": "Finestra archivio",
"PLAYBACK_SUPPORTED": "Riproduzione supportata",
"EPG_PARAMS": "Parametri EPG",
"TIMESHIFT": "Timeshift",
"TVG_ID": "TVG ID",
"TVG_NAME": "Nome TVG",
"TVG_URL": "URL TVG",
"TVG_LOGO": "Logo TVG",
"TVG_REC": "TVG rec",
"CATCHUP_TYPE": "Tipo Catchup",
"CATCHUP_SOURCE": "Sorgente Catchup",
"CATCHUP_DAYS": "Giorni Catchup",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "Non specificato",
"NOT_AVAILABLE": "Non disponibile",
"DAYS_ONE": "1 giorno",
"DAYS_OTHER": "{{count}} giorni"
},
"GROUP_MANAGEMENT": {
"TITLE": "Gestisci gruppi",
"SELECTED": "Selezionati",
"SELECT_ALL": "Seleziona tutti",
"DESELECT_ALL": "Deseleziona tutti",
"SEARCH_PLACEHOLDER": "Cerca gruppi...",
"NO_RESULTS": "Nessun gruppo corrispondente",
"SAVE": "Salva"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Apri elenco canali",
"TOGGLE_FAVORITE_FLAG": "Attiva/disattiva flag preferiti",
"OPEN_EPG_LIST": "Apri elenco EPG",
"OPEN_MULTI_EPG": "Apri vista Multi-EPG"
"OPEN_MULTI_EPG": "Apri vista Multi-EPG",
"SHOW_INFO": "Mostra info programma (premi I)"
},
"EPG": {
"TITLE": "Guida elettronica programmi",
@@ -288,12 +445,16 @@
"LIVE_NOW": "Live attuale",
"LIVE_STREAM": "Stream live",
"TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Riproduzione archivio non disponibile",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "Questo fornitore espone la cronologia della programmazione del canale, ma non la riproduzione di archivio/catch-up.",
"EPG_NOT_AVAILABLE_DATE": "Non è disponibile l'EPG per la data selezionata",
"NO_GUIDE_HINT": "Prova una data diversa o aggiorna la playlist.",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Non è disponibile l'EPG per questo canale.",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Aggiungi/modifica l'URL dell'elenco EPG nelle impostazioni dell'app.",
"FETCH_EPG": "Recupero dati EPG...",
"ERROR": "Non è stato possibile caricare l'EPG.",
"DOWNLOAD_SUCCESS": "L'EPG è stato correttamente scaricato.",
"NO_PROGRAM_INFO": "Nessuna informazione sul programma disponibile",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "Dettagli sul programma TV",
"TITLE": "Titolo",
@@ -301,9 +462,23 @@
"PARENTAL_RATING_SYSTEM": "Sistema classificazione contenuti TV",
"DESCRIPTION": "Descrizione",
"LANGUAGE": "Lingua",
"SHOW_PROGRAM_DETAILS": "Visualizza dettagli su questo programma"
"SHOW_PROGRAM_DETAILS": "Visualizza dettagli su questo programma",
"EPISODE": "Episodio",
"DURATION": "Durata"
},
"FETCH_SUCCESS": "EPG scaricato correttamente"
"FETCH_SUCCESS": "EPG scaricato correttamente",
"IMPORT_PROGRESS": "Importazione EPG",
"CHANNELS": "canali",
"PROGRAMS": "programmi",
"LOADING": "Caricamento",
"QUEUED": "In coda",
"TOTAL_CHANNELS": "Totale canali",
"TOTAL_PROGRAMS": "Totale programmi",
"UP_TO_DATE": "L'EPG è aggiornato, nessuna sincronizzazione necessaria",
"RETRY": "Riprova",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -350,6 +525,7 @@
"REFRESH": "Aggiorna",
"XTREAM": {
"PLAY": "Riproduci",
"OPENING_PLAYER": "Apertura del lettore...",
"NAME": "Nome",
"RELEASE_DATE": "Data rilascio",
"GENRE": "Genere",
@@ -366,7 +542,59 @@
"YEAR": "Anno",
"AGE": "Età",
"YOUTUBE_TRAILER": "Trailer YouTube",
"EPISODE": "Episodio"
"EPISODE": "Episodio",
"RESUME": "Riprendi",
"RESTART": "Ricomincia",
"CONTINUE_WATCHING": "Continua a guardare",
"WATCHED": "Visto",
"IN_PROGRESS": "In corso",
"MARK_WATCHED": "Segna come visto",
"MARK_UNWATCHED": "Segna come non visto",
"EPISODES_WATCHED": "{{count}} di {{total}} episodi visti",
"DETAIL_FALLBACK": {
"STATUS": "Metadati del portale non disponibili",
"NOTE": "Questo portale non ha fornito metadati estesi.",
"STREAM_ID": "ID stream",
"CATEGORY": "Categoria",
"CONTAINER": "Formato",
"SOURCE_TITLE": "Titolo sorgente"
},
"ACCOUNT_INFO": {
"TITLE": "Informazioni account",
"USER_INFO": "Informazioni utente",
"STATUS": "Stato",
"USERNAME": "Nome utente",
"ACTIVE_CONNECTIONS": "Connessioni attive",
"CREATED": "Creato",
"EXPIRES": "Scade",
"TRIAL_ACCOUNT": "Account di prova",
"YES": "Sì",
"NO": "No",
"ALLOWED_FORMATS": "Formati consentiti",
"SERVER_INFO": "Informazioni server",
"LIVE_TV": "TV in diretta",
"MOVIES": "Film",
"TV_SERIES": "Serie TV",
"URL": "URL",
"PROTOCOL": "Protocollo",
"TIMEZONE": "Fuso orario",
"SERVER_TIME": "Ora server",
"PORTS": "Porte",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
},
"CATEGORY_MANAGEMENT": {
"TITLE": "Gestisci categorie",
"LOADING": "Caricamento categorie...",
"SELECTED": "Selezionate",
"SELECT_ALL": "Seleziona tutte",
"DESELECT_ALL": "Deseleziona tutte",
"SEARCH_PLACEHOLDER": "Cerca categorie...",
"NO_RESULTS": "Nessuna categoria corrispondente",
"NO_CATEGORIES": "Nessuna categoria disponibile",
"SAVE": "Salva"
}
},
"PORTALS": {
"ALL_CATEGORIES": "Tutte le categorie",
@@ -375,11 +603,47 @@
"REMOVE_FROM_FAVORITES": "Rimuovi dai preferiti",
"REMOVED_FROM_FAVORITES": "Rimosso dai preferiti",
"ADDED_TO_FAVORITES": "Aggiunto ai preferiti",
"FAVORITES": "Preferiti",
"RECENTLY_VIEWED": "Visti di recente",
"THIS_PLAYLIST": "Questa playlist",
"ALL_PLAYLISTS": "Tutte le playlist",
"LIVE_TV": "TV in diretta",
"MOVIES": "Film",
"SERIES": "Serie",
"SEARCH": "Cerca",
"NO_FAVORITES": "Ancora nessun preferito",
"NO_RECENT_ITEMS": "Nessun elemento visto di recente",
"REMOVE_FROM_RECENT": "Rimuovi dai visti di recente",
"BROWSE_ALL_MOVIES": "Sfoglia tutti i film",
"BROWSE_ALL_SERIES": "Sfoglia tutte le serie",
"BROWSE_ALL_LIVE": "Sfoglia tutta la TV in diretta",
"SIDEBAR": {
"MAIN": "Principale",
"MOVIES": "Film",
"LIVE_TV": "TV in diretta",
"SERIES": "Serie",
"LIBRARY": "Libreria",
"SEARCH": "Cerca",
"RECENT": "Recenti",
"RECENTLY_ADDED": "Aggiunti di recente",
"FAVORITES": "Preferiti",
"HOME": "Home",
"PLAYLIST_INFO": "Info playlist",
"ACCOUNT_INFO": "Info account"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
"TITLE": "Importazione annullata",
"DESCRIPTION": "L'importazione della playlist è stata annullata. Riprova quando vuoi continuare a caricare questa sorgente."
},
"ACCOUNT_EXPIRED": {
"TITLE": "Account scaduto",
"DESCRIPTION": "Questo account è scaduto. Controlla le credenziali nelle impostazioni della playlist o rimuovile."
},
"ACCOUNT_INACTIVE": {
"TITLE": "Account inattivo",
"DESCRIPTION": "Questo account è inattivo. Controlla le credenziali nelle impostazioni della playlist o rimuovile."
},
"UNAUTHORIZED": {
"TITLE": "Non autorizzato",
"DESCRIPTION": "Controlla le credenziali nelle impostazioni della playlist o rimuovile."
@@ -388,6 +652,10 @@
"TITLE": "Portale non trovato",
"DESCRIPTION": "Controlla le credenziali e l'URL del portale nelle impostazioni della playlist o rimuovile."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "Portale non disponibile",
"DESCRIPTION": "Impossibile raggiungere il portale. Controlla l'URL del server o riprova quando la sorgente sarà di nuovo disponibile."
},
"EMPTY_CATEGORY": {
"TITLE": "Categoria vuota",
"DESCRIPTION": "Questa categoria non ha contenuti, seleziona un'altra categoria o modifica il tipo di contenuto"
@@ -396,30 +664,173 @@
"TITLE": "Qualcosa è andato storto",
"DESCRIPTION": "Errore sconosciuto, riprova più tardi"
},
"NO_CATEGORY_SELECTED": {
"TITLE": "Nessuna categoria selezionata",
"DESCRIPTION": "Seleziona una categoria per vedere i contenuti"
},
"PLAYLIST_SETTINGS": "Impostazioni Playlist",
"HOME": "Home",
"DELETE": "Elimina"
},
"EMPTY_LIST_VIEW": {
"TITLE": "Nessun risultato trovato",
"NO_SEARCH_RESULTS": "Nessun risultato, prova a modificare la richiesta di ricerca"
},
"SEARCH_VIEW": {
"INITIAL_TITLE": "Cerca contenuti",
"INITIAL_DESCRIPTION": "Inserisci almeno 3 caratteri per cercare tra TV in diretta, film e serie.",
"RESULTS_FOUND": "Trovati {{count}} risultati",
"NO_RESULTS_FOR": "Nessun risultato trovato per \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Raggruppa risultati per playlist",
"XTREAM_ONLY_NOTE": "La ricerca globale è attualmente disponibile solo per le playlist Xtream.",
"VISIBLE_CATEGORIES_ONLY": "Solo categorie visibili"
},
"STREAM_URL_COPIED": "URL streaming copiato negli appunti",
"COPY_STREAM_URL": "Copia URL streaming",
"SEARCH_VIEW": {
"RESULTS_FOUND": "Trovati {{count}} risultati",
"NO_RESULTS_FOR": "Nessun risultato trovato per \"{{term}}\""
}
"SELECT_CATEGORY": "Seleziona una categoria",
"PAGE": "Pagina",
"CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server",
"PLAYBACK_ERROR": "Impossibile ottenere l'URL di riproduzione",
"LOADING_SEASONS": "Caricamento stagioni...",
"NO_EPISODES": "Nessun episodio disponibile",
"ALL_RADIO": "Tutte le radio"
},
"INFORMATION": "Informazioni",
"DOWNLOADS": {
"TITLE": "Download",
"DOWNLOAD": "Scarica",
"PLAY_LOCAL": "Riproduci locale",
"EMPTY": "Ancora nessun download",
"EMPTY_HINT": "Scarica film ed episodi per guardarli offline",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term",
"NOT_AVAILABLE": "Download non disponibili",
"DESKTOP_ONLY": "Questa funzionalità è disponibile solo nell'app desktop",
"NO_FOLDER": "Nessuna cartella di download selezionata",
"CHANGE_FOLDER": "Cambia cartella",
"CLEAR_COMPLETED": "Cancella completati",
"CLEAR_COMPLETED_DIALOG": {
"TITLE": "Cancella download completati",
"MESSAGE": "Vengono rimossi gli elementi completati, falliti e annullati dalla vista corrente dei download. I file già scaricati su disco non vengono eliminati."
},
"SOURCE_PLAYLIST_MISSING": "Questo download non può più essere aperto nella libreria perché la playlist di origine è stata rimossa.",
"FILE_NOT_FOUND": "Questo file scaricato non è più disponibile su disco.",
"FILE_ACTION_ERROR": "Impossibile completare l'azione sul file.",
"CANCEL": "Annulla",
"RETRY": "Riprova",
"REMOVE": "Rimuovi",
"PLAY": "Riproduci",
"REVEAL": "Mostra nella cartella",
"COPY_URL": "Copia URL di download",
"URL_COPIED": "URL di download copiato negli appunti",
"URL_COPY_FAILED": "Impossibile copiare l'URL negli appunti",
"STATUS": {
"QUEUED": "In coda",
"DOWNLOADING": "In download",
"COMPLETED": "Completato",
"FAILED": "Non riuscito",
"CANCELED": "Annullato"
}
},
"REMOTE_CONTROL": {
"TITLE": "Controllo remoto",
"SUBTITLE": "Controlla il tuo lettore IPTV da remoto",
"UP_CHANNEL": "Canale +",
"DOWN_CHANNEL": "Canale -",
"HEADER": "Controllo remoto IPTVNator",
"FOOTER": "Controllo remoto IPTVNator"
},
"WORKSPACE": {
"FAVORITES_ONLY": "Solo preferiti",
"SORT_CUSTOM": "Ordine personalizzato",
"SORT_DATE_DESC": "Data aggiunta (prima i più recenti)",
"SORT_DATE_ASC": "Data aggiunta (prima i più vecchi)",
"SORT_NAME_ASC": "Nome A-Z",
"SORT_NAME_DESC": "Nome Z-A",
"SORT_LABEL": "Ordina: ",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continua a guardare",
"UNTITLED_SOURCE": "Sorgente senza titolo",
"GLOBAL_FAVORITES": "Preferiti globali",
"REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia",
"RECENT_SOURCES": "Sorgenti usate di recente",
"MANAGE_ALL": "Gestisci tutto",
"RECENTLY_WATCHED": "Visti di recente",
"XTREAM_RECENTLY_ADDED": "Aggiunti di recente su Xtream",
"NO_HISTORY": "Nessun elemento corrisponde a questo filtro.",
"NO_HISTORY_HINT": "La cronologia di visualizzazione apparirà qui non appena inizi a riprodurre contenuti dalle tue playlist.",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U",
"STALKER_PORTAL": "Portale Stalker",
"PROVIDER": "Fornitore",
"TYPE_LIVE": "Live",
"TYPE_MOVIE": "Film",
"TYPE_SERIES": "Serie",
"NOT_YET_SYNCED": "Non ancora sincronizzato"
},
"SHELL": {
"BRAND_ALT": "IPTVnator",
"RAIL_DASHBOARD": "Dashboard",
"OPEN_DASHBOARD": "Apri dashboard",
"RAIL_SOURCES": "Sorgenti",
"OPEN_SOURCES": "Apri sorgenti",
"RAIL_GLOBAL_FAVORITES": "Preferiti",
"RAIL_GLOBAL_RECENT": "Visti di recente",
"RAIL_SETTINGS": "Impostazioni",
"OPEN_SETTINGS": "Apri impostazioni",
"RAIL_LIBRARY": "Libreria",
"RAIL_MOVIES": "Film",
"RAIL_LIVE": "TV in diretta",
"RAIL_SERIES": "Serie",
"RAIL_RECENTLY_ADDED": "Aggiunti di recente",
"RAIL_SEARCH": "Ricerca avanzata",
"RAIL_RECENT": "Visti di recente",
"RAIL_FAVORITES": "Preferiti",
"RAIL_DOWNLOADS": "Download",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"RAIL_ALL_CHANNELS": "Tutti i canali",
"RAIL_GROUPS": "Gruppi",
"RAIL_CONTEXT_ACTIONS": "Azioni di sezione",
"SEARCH_LABEL": "Cerca",
"SEARCH_SOURCES_PLACEHOLDER": "Cerca sorgenti (tutte le playlist)...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Cerca in questa playlist...",
"SEARCH_SECTION_PLACEHOLDER": "Cerca in questa sezione...",
"FILTER_SECTION_PLACEHOLDER": "Filtra questa sezione...",
"SEARCH_STATUS_LOADED_ONLY": "Solo canali caricati",
"COMMAND_PALETTE_SHORTCUT": "Tavolozza comandi (Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "Apri tavolozza comandi",
"ADD_PLAYLIST_ALL": "Aggiungi playlist",
"ADD_PLAYLIST": "Aggiungi playlist",
"OPEN_DOWNLOADS": "Apri download",
"OPEN_CONTEXT_ACTIONS": "Apri azioni di sezione",
"NO_SECTION_ACTIONS": "Nessuna azione per questa sezione",
"PLAYLIST_INFO": "Info playlist",
"ACCOUNT_INFO": "Info account",
"REFRESH_PLAYLIST": "Aggiorna playlist",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "Cancella download completati (tutte le playlist)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Cancella i download completati per tutte le playlist",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Cancella download completati (questa playlist)",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "Cancella i download completati per questa playlist",
"CLEAR_RECENTLY_VIEWED_SECTION": "Cancella visti di recente",
"CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "Cancella visti di recente per questa sezione",
"CLEAR_RECENTLY_VIEWED_TYPE": "Cancella visti di recente: {{type}}",
"CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "Cancellare visti di recente: {{type}}?",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "Tutti gli elementi {{type}} di questa playlist verranno rimossi dalla cronologia dei visti di recente. L'azione non può essere annullata.",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL": "Tutti gli elementi {{type}} di tutte le playlist verranno rimossi dalla cronologia dei visti di recente. L'azione non può essere annullata.",
"CLEAR_FAVORITES_TYPE": "Cancella preferiti {{type}}",
"CLEAR_FAVORITES_DIALOG_TITLE": "Rimuovere tutti i preferiti {{type}}?",
"CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST": "Tutti i preferiti {{type}} di questa playlist verranno rimossi. L'azione non può essere annullata.",
"CLEAR_FAVORITES_DIALOG_MESSAGE_ALL": "Tutti i preferiti {{type}} di tutte le playlist verranno rimossi. L'azione non può essere annullata.",
"XTREAM_CODE": "Codice Xtream",
"STALKER_PORTAL": "Portale Stalker",
"CHANNELS_COUNT": "{{count}} canali",
"NO_SOURCES_AVAILABLE": "Nessuna sorgente disponibile",
"ONE_SOURCE_AVAILABLE": "1 sorgente disponibile",
"SOURCES_AVAILABLE": "{{count}} sorgenti disponibili",
"GO_TO_DASHBOARD": "Back to dashboard",
"GO_TO_SOURCES": "Go to Sources",
"DROP_OVERLAY_TITLE": "Drop to add playlist",
@@ -429,21 +840,129 @@
"DROP_OVERLAY_REJECTED_EMPTY": "That file is empty",
"DROP_OVERLAY_REJECTED_READ_ERROR": "Could not read that file",
"DROP_IMPORT_SUCCESS": "Importing \"{{title}}\"…",
"XTREAM_IMPORT_TITLE": "Sincronizzazione playlist",
"XTREAM_IMPORT_REMOTE_BADGE": "Sorgente remota",
"XTREAM_IMPORT_LOCAL_BADGE": "Libreria locale",
"XTREAM_IMPORT_DETAIL_REMOTE": "In attesa della risposta del fornitore.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparazione di un cambio playlist più rapido per la prossima visita.",
"XTREAM_IMPORT_PREPARING": "Preparazione libreria locale...",
"XTREAM_IMPORT_LOADING": "Recupero dati playlist dalla sorgente...",
"XTREAM_IMPORT_SAVING": "Salvataggio dati playlist in locale...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Ripristino preferiti dalla libreria locale...",
"XTREAM_IMPORT_RESTORING_RECENT": "Ripristino visti di recente dalla libreria locale...",
"XTREAM_IMPORT_PROGRESS": "{{type}} importati: {{current}} / {{total}}",
"XTREAM_IMPORT_CANCEL": "Interrompi sincronizzazione",
"XTREAM_IMPORT_CANCELLING": "Interruzione sincronizzazione...",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Cerca in tutte le playlist Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "Apri overlay di ricerca globale",
"PLAYLIST_SEARCH_LABEL": "Ricerca avanzata in questa playlist",
"PLAYLIST_SEARCH_DESCRIPTION": "Apri ricerca avanzata della playlist",
"OPEN_GLOBAL_FAVORITES_LABEL": "Apri preferiti globali",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "Vai ai preferiti aggregati",
"OPEN_DOWNLOADS_LABEL": "Apri download",
"OPEN_DOWNLOADS_DESCRIPTION": "Vai alla vista download",
"OPEN_GLOBAL_RECENT_LABEL": "Apri visti di recente",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Apri pagina globale dei visti di recente",
"OPEN_SETTINGS_LABEL": "Apri impostazioni",
"OPEN_SETTINGS_DESCRIPTION": "Vai alle impostazioni dell'applicazione",
"OPEN_SOURCES_LABEL": "Gestisci sorgenti",
"OPEN_SOURCES_DESCRIPTION": "Vai alla gestione delle sorgenti",
"OPEN_DASHBOARD_LABEL": "Apri dashboard",
"OPEN_DASHBOARD_DESCRIPTION": "Vai alla dashboard",
"ADD_PLAYLIST_LABEL": "Aggiungi nuova sorgente",
"ADD_PLAYLIST_DESCRIPTION": "Apri finestra di aggiunta playlist",
"ADD_PLAYLIST_M3U_LABEL": "Add M3U playlist",
"ADD_PLAYLIST_M3U_DESCRIPTION": "Open add dialog on the M3U file or URL tab",
"ADD_PLAYLIST_XTREAM_LABEL": "Add Xtream Codes playlist",
"ADD_PLAYLIST_XTREAM_DESCRIPTION": "Open add dialog on the Xtream Codes tab",
"ADD_PLAYLIST_STALKER_LABEL": "Add Stalker portal",
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab"
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab",
"REFRESH_PLAYLIST_LABEL": "Aggiorna playlist",
"REFRESH_PLAYLIST_DESCRIPTION": "Risincronizza i dati dalla sorgente remota",
"PLAYLIST_INFO_LABEL": "Dettagli playlist",
"PLAYLIST_INFO_DESCRIPTION": "Mostra le informazioni sulla playlist corrente",
"ACCOUNT_INFO_LABEL": "Info account",
"ACCOUNT_INFO_DESCRIPTION": "Mostra i dettagli account del server Xtream",
"OPEN_VIEW_DESCRIPTION": "Vai a {{view}}",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Cancella tutti gli elementi {{type}} visibili in questa vista",
"OPEN_MULTI_EPG_DESCRIPTION": "Apri la vista EPG multi-canale",
"GO_TO_VOD_LABEL": "Film",
"GO_TO_VOD_DESCRIPTION": "Vai alla sezione film",
"GO_TO_LIVE_LABEL": "TV in diretta",
"GO_TO_LIVE_DESCRIPTION": "Vai alla sezione TV in diretta",
"GO_TO_SERIES_LABEL": "Serie",
"GO_TO_SERIES_DESCRIPTION": "Vai alla sezione serie",
"SWITCH_PLAYER_LABEL": "Passa al lettore {{name}}",
"SWITCH_PLAYER_DESCRIPTION": "Imposta {{name}} come lettore video attivo",
"SWITCH_PLAYER_FEEDBACK": "Lettore impostato su {{name}}"
},
"EXTERNAL_PLAYBACK_CLOSE": "Close player"
"XTREAM_REFRESH_TITLE": "Aggiornamento playlist",
"XTREAM_REFRESH_COLLECTING_DATA": "Conservazione dei dati della libreria...",
"XTREAM_REFRESH_DELETING_CONTENT": "Rimozione degli stream in cache...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "Rimozione delle categorie in cache...",
"XTREAM_REFRESH_DETAIL_LOCAL": "Preferiti, cronologia di visione, categorie nascoste e avanzamento di riproduzione vengono preparati per il ripristino dopo la sincronizzazione.",
"XTREAM_REFRESH_PROGRESS": "Record locali elaborati: {{current}} / {{total}}",
"RAIL_RADIO": "Radio"
},
"SOURCES": {
"TITLE": "Sorgenti",
"SUBTITLE": "Filtra e ordina le playlist",
"TYPE": "Tipo",
"ALL": "Tutte",
"M3U_PLAYLISTS": "Playlist M3U",
"XTREAM_PLAYLISTS": "Playlist Xtream",
"STALKER_PLAYLISTS": "Playlist Stalker",
"ALL_PLAYLISTS": "Tutte le playlist",
"PLAYLIST_COUNT_ONE": "1 playlist",
"PLAYLIST_COUNT_OTHER": "{{count}} playlist"
},
"CONTEXT": {
"CATEGORIES": "Categorie",
"MOVIE_CATEGORIES": "Categorie film",
"SERIES_CATEGORIES": "Categorie serie",
"LIVE_CATEGORIES": "Categorie live",
"XTREAM_SYNCING_MOVIES": "Sincronizzazione categorie film...",
"XTREAM_SYNCING_SERIES": "Sincronizzazione categorie serie...",
"XTREAM_SYNCING_LIVE": "Sincronizzazione categorie live...",
"XTREAM_SOURCE": "Sorgente Xtream",
"STALKER_PORTAL": "Portale Stalker",
"MANAGE_CATEGORIES": "Gestisci categorie",
"LOAD_CATEGORIES_ERROR": "Impossibile caricare le categorie",
"LOAD_CATEGORIES_ERROR_HINT": "Riprova o verifica le impostazioni del portale.",
"FILTER_BY_TYPE": "Filtra per tipo",
"ITEM_COUNT_ONE": "1 elemento",
"ITEM_COUNT_OTHER": "{{count}} elementi",
"SEARCH": "Cerca",
"SEARCH_CATEGORIES": "Cerca categorie",
"RADIO_CATEGORIES": "Categorie radio"
},
"COMMAND_PALETTE": {
"TITLE": "Tavolozza comandi",
"SUBTITLE": "Azioni globali e contestuali",
"SEARCH_PLACEHOLDER": "Digita per filtrare i comandi",
"SEARCH_LABEL": "Filtra comandi",
"EMPTY": "Nessun comando trovato",
"GROUP_GLOBAL": "Azioni globali",
"GROUP_PLAYLIST": "Questa playlist",
"GROUP_VIEW": "Vista corrente",
"GROUP_SECTION": "Questa sezione",
"GROUP_RECENT": "Usati di recente"
},
"GLOBAL_FAVORITES": {
"TITLE": "Preferiti globali",
"SUBTITLE": "TV in diretta da tutte le playlist",
"SEARCH_PLACEHOLDER": "Cerca tra i preferiti...",
"NO_RESULTS": "Nessun risultato",
"NO_RESULTS_HINT": "Prova con un altro termine di ricerca",
"EMPTY_TITLE": "Ancora nessun preferito di TV in diretta",
"EMPTY_HINT": "Aggiungi una stella ai canali live di playlist M3U, Xtream o Stalker per vederli qui.",
"EMPTY_PLAYER": "Seleziona un canale per iniziare a guardare",
"NO_PLAYLISTS_TITLE": "No favorites to show",
"NO_PLAYLISTS_BODY": "Add a playlist to start collecting favorites from live channels, movies, and series.",
"NO_ITEMS_TITLE": "No favorites yet",
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here."
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here.",
"UNKNOWN_CHANNEL": "Sconosciuto"
},
"GLOBAL_RECENT": {
"NO_PLAYLISTS_TITLE": "Nothing watched yet",
@@ -451,12 +970,5 @@
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
}
},
"DOWNLOADS": {
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term"
}
}
+628 -62
View File
@@ -1,5 +1,9 @@
{
"CANCEL": "キャンセル",
"CLEAR": "クリア",
"CLOSE": "閉じる",
"SHOW_MORE": "もっと見る",
"SHOW_LESS": "折りたたむ",
"PLAYLISTS": "プレイリスト",
"HOME": {
"TITLE": "IPTVnator",
@@ -15,6 +19,44 @@
"PLAYLISTS": {
"NO_PLAYLISTS": "プレイリストが追加されていません",
"ADD_FIRST": "最初のプレイリストを追加してください",
"WELCOME_TITLE": "IPTVnatorへようこそ",
"WELCOME_DESCRIPTION": "最初のプレイリストを追加してストリーミングを始めましょう。お好みのIPTVサービスに接続して、ライブTV、映画、シリーズをお楽しみください。",
"ADD_YOUR_FIRST": "最初のプレイリストを追加",
"FEATURE_M3U": "M3U/M3U8ファイル",
"FEATURE_XTREAM": "Xtream Codes",
"FEATURE_STALKER": "Stalkerポータル",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"NO_RESULTS_TITLE": "プレイリストが見つかりません",
"NO_RESULTS_DESCRIPTION": "検索条件に一致するプレイリストはありません。検索キーワードを変更するか、フィルターをクリアしてすべてのプレイリストを表示してください。",
"ADDED_VIA_URL": "URLからアップロード。URL:",
"ADDED_VIA_FILE": "ファイルからアップロード",
"XTREAM_PLAYLIST": "Xtremeプレイリスト",
@@ -43,60 +85,64 @@
"SERVER_URL": "サーバーURL",
"USERNAME": "ユーザー名",
"PASSWORD": "パスワード",
"MAC_ADDRESS": "MACアドレス"
"MAC_ADDRESS": "MACアドレス",
"PLAYLIST_EXPORT_SUCCESS": "プレイリストを正常にエクスポートしました。",
"PLAYLIST_EXPORT_ERROR": "エラー:プレイリストのエクスポートに失敗しました。",
"COPY_URL": "URLをクリップボードにコピー",
"URL_COPIED": "URLをクリップボードにコピーしました"
},
"REMOVE_DIALOG": {
"TITLE": "プレイリストを削除",
"MESSAGE": "このプレイリストを完全に削除してもよろしいですか?"
"MESSAGE": "このプレイリストを完全に削除してもよろしいですか?",
"SUCCESS": "プレイリストを削除しました",
"IN_PROGRESS": "プレイリストを削除中...",
"DELETING_FAVORITES": "お気に入りを削除中...",
"DELETING_RECENT": "最近表示した項目を削除中...",
"DELETING_PROGRESS": "再生位置を削除中...",
"DELETING_DOWNLOADS": "ダウンロードを削除中...",
"DELETING_CONTENT": "キャッシュされたコンテンツを削除中...",
"DELETING_CATEGORIES": "カテゴリーを削除中...",
"DELETING_PLAYLIST": "プレイリストレコードを削除中..."
},
"UPDATED": "更新済み",
"REFRESH": "プレイリストを更新",
"REFRESH_XTREAM": "リモートからXtreamプレイリストを更新",
"REFRESH_XTREAM_DIALOG": {
"TITLE": "Xtreamプレイリストを更新",
"MESSAGE": "ローカルのXtreamキャッシュを削除し、リモートサーバーからカテゴリーとストリームを再インポートします。お気に入り、最近表示した項目、非表示カテゴリーの設定、再生位置は、再インポート成功後に対応するコンテンツが存在する場合に復元されます。サーバーに接続できない場合、復元は次回の更新成功まで保留されます。続行しますか?",
"STARTED": "更新を開始しました。プレイリストへ移動します...",
"ERROR": "プレイリストの更新に失敗しました。もう一度お試しください。",
"IN_PROGRESS": "プレイリストを更新中...",
"COLLECTING_DATA": "お気に入りと最近表示した項目を収集中...",
"DELETING_CONTENT": "キャッシュされたコンテンツを削除中...",
"DELETING_CATEGORIES": "キャッシュされたカテゴリーを削除中..."
},
"GLOBAL_FAVORITES": "グローバルお気に入り",
"GLOBAL_FAVORITES_DESCRIPTION": "全プレイリストのお気に入りを集約した自動生成プレイリスト",
"MY_PLAYLISTS": "マイプレイリスト",
"MY_PLAYLISTS_SUBTITLE": "利用可能な全プレイリスト",
"PLAYLIST_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました。",
"FEATURE_M3U_LABEL": "Add an M3U file or URL",
"FEATURE_XTREAM_LABEL": "Connect via Xtream Codes",
"FEATURE_STALKER_LABEL": "Connect to a Stalker portal",
"VALUE_PROPS_LABEL": "What you can do",
"VALUE_PROP_LIVE": "Live TV with EPG",
"VALUE_PROP_VOD": "Movies & Series",
"VALUE_PROP_MULTISOURCE": "Multi-source library",
"VALUE_PROP_OFFLINE": "Offline downloads",
"DASHBOARD_WELCOME_TAGLINE": "Your IPTV library, in one place.",
"FEATURE_LIVE_TITLE": "Live TV",
"FEATURE_LIVE_DESC": "Stream live channels with full schedule.",
"FEATURE_VOD_TITLE": "Movies & Series",
"FEATURE_VOD_DESC": "On-demand library with covers and details.",
"FEATURE_EPG_TITLE": "EPG & Catch-up",
"FEATURE_EPG_DESC": "Program guide and time-shifted playback.",
"FEATURE_OFFLINE_TITLE": "Offline downloads",
"FEATURE_OFFLINE_DESC": "Save movies and episodes for later.",
"SOURCES_WELCOME_TITLE": "Add your first playlist",
"SOURCES_WELCOME_BODY": "Pick a source type below to get started.",
"SOURCE_M3U_NEEDS": "Local file or URL pointing to an M3U/M3U8 playlist.",
"SOURCE_M3U_ADD": "Add M3U",
"SOURCE_XTREAM_NEEDS": "Username, password and server URL from your provider.",
"SOURCE_XTREAM_ADD": "Add Xtream",
"SOURCE_STALKER_NEEDS": "Portal URL and MAC address (Ministra-compatible).",
"SOURCE_STALKER_ADD": "Add Stalker",
"CONTENT_LIVE": "Live",
"CONTENT_VOD": "Movies",
"CONTENT_SERIES": "Series",
"CONTENT_EPG": "EPG",
"DASHBOARD_WELCOME_DESCRIPTION": "Connect M3U files, Xtream Codes, or Stalker portals — then enjoy live TV, movies and series on every screen."
"PLAYLIST_UPDATE_ERROR": "プレイリストの詳細を更新できませんでした。",
"PLAYLIST_UPDATE_FILE_NOT_FOUND": "プレイリストの更新に失敗しました。ローカルファイルが利用できなくなっています。ファイルパスを確認するか、プレイリストを再インポートしてください。",
"PLAYLIST_UPDATE_FILE_ACCESS_ERROR": "プレイリストの更新に失敗しました。アプリがローカルファイルにアクセスできなくなりました。",
"AUTO_REFRESH_UPDATE_SUCCESS": "成功!プレイリストが正常に更新されました",
"AUTO_REFRESH_ENABLED": "自動更新が有効です"
},
"FILE_UPLOAD": {
"DRAG_DROP": "ここにファイルをドラッグ&ドロップ",
"OR": "または",
"CHOOSE_PLAYLIST": "プレイリストを選択",
"BROWSE": "ファイルを参照",
"REJECTED": "ファイルが拒否されました。サポートされていないファイル形式です:{{filename}}"
},
"URL_UPLOAD": {
"PLAYLIST_URL": "プレイリストURL (m3u, m3u8)",
"ADD_PLAYLIST": "プレイリストを追加",
"CORS_NOTE": "注意:CORSの問題を回避するため、現時点ではパブリックCORSプロキシサービスを使用しています。機密データを含むプレイリストをアップロードする場合は、ファイルからインポートする方が安全です。"
"CORS_NOTE": "注意:CORSの問題を回避するため、現時点ではパブリックCORSプロキシサービスを使用しています。機密データを含むプレイリストをアップロードする場合は、ファイルからインポートする方が安全です。",
"ERROR_FETCH_FAILED": "プレイリストを取得できませんでした。URLを確認してもう一度お試しください。",
"ERROR_403": "アクセスが拒否されました(403):サーバーがリクエストを拒否しました。URLに認証が必要か、プレイリストが制限されている可能性があります。",
"ERROR_404": "プレイリストが見つかりません(404):URLは有効なプレイリストを指していません。URLを確認してください。",
"ERROR_401": "未認証(401):このプレイリストにアクセスするには認証が必要です。"
},
"TEXT_IMPORT": {
"LABEL": "m3u(8)プレイリストをテキストとして挿入",
@@ -114,17 +160,68 @@
"STALKER_PORTAL": {
"TITLE": "タイトル",
"MAC_ADDRESS": "MACアドレス",
"SERIAL_NUMBER": "シリアル番号(任意)",
"SERIAL_NUMBER_HINT": "プロバイダーに登録済みの場合に使用",
"DEVICE_ID_1": "デバイスID 1(任意)",
"DEVICE_ID_2": "デバイスID 2(任意)",
"DEVICE_ID_HINT": "16進64文字。プロバイダーが特定のデバイスIDを要求する場合に使用",
"SIGNATURE_1": "署名 1(任意)",
"SIGNATURE_2": "署名 2(任意)",
"SIGNATURE_HINT": "16進64文字。プロバイダーがデバイス検証署名を要求する場合に使用",
"SERVER_URL": "サーバーURL",
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com/c または https://example.com/stalker_portal/c)",
"ADD": "追加"
"ADD": "追加",
"VALIDATING": "ポータルを検証中..."
},
"FILTER_BY_NAME": "名前でフィルター",
"FILTER_BY_TYPE": "種類でフィルター",
"FILTER_AND_SORT": "フィルターと並び替え",
"SORT_PLAYLISTS": "プレイリストを並び替え",
"SORT_BY": "並び替え",
"SORT_OPTIONS": {
"NAME_ASC": "名前(A-Z)",
"NAME_DESC": "名前(Z-A)",
"NEWEST": "追加日(新しい順)",
"OLDEST": "追加日(古い順)",
"CUSTOM_ORDER": "カスタム順(Drag & Drop)"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(ローカル、URL、テキスト)",
"XTREAM": "Xtream",
"STALKER": "Stalker"
},
"IMPORTED_AS_TEXT": "テキストとしてインポート",
"PARSING_ERROR": "エラー:解析に失敗しました。有効なプレイリストではありません:"
},
"SETTINGS": {
"TITLE": "設定",
"GENERAL": "一般",
"GENERAL_SUBTITLE": "アプリケーションの設定を変更",
"PLAYBACK": "再生",
"EPG_SOURCES": "EPGソース",
"EPG_SOURCES_DESCRIPTION": "EPGソースとして1つ以上のURLを追加",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"PREFER_UPLOADED_EPG_TITLE": "Xtream プレイリストでアップロードした EPG を優先する",
"PREFER_UPLOADED_EPG_HINT": "標準では Xtream プレイリストはプロバイダの番組表を使用し、アップロードした EPG は不足しているチャンネルのみを補完します。これを有効にすると、アップロードした EPG を優先して使用します。",
"DATA": "データ",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"ABOUT": "アプリについて",
"NAV_GENERAL": "一般",
"NAV_PLAYBACK": "再生",
"NAV_EPG": "EPG",
"NAV_REMOTE": "リモート",
"NAV_DATA": "データ",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "アプリについて",
"APP_PREFERENCES": "アプリ設定",
"HERO_EYEBROW": "ワークスペース設定",
"HERO_TITLE": "IPTVnatorをカスタマイズ",
"HERO_DESCRIPTION": "現在のワークスペースの見た目を保ったまま、再生、番組表、リモートアクセス、データ管理を一カ所で調整できます。",
"SUBTITLE": "アプリケーションの設定を変更",
"DESCRIPTION": "以下のオプションを調整し、変更を保存ボタンで確定してください",
"EPG_URL_LABEL": "EPG URL (*.xml または *.xml.gz)",
@@ -136,29 +233,72 @@
"EPG_STATUS_STALE": "更新が必要",
"EPG_STATUS_ERROR": "失敗",
"VIDEO_PLAYER_LABEL": "ビデオプレーヤー",
"PLAYER_HTML5": "HTML5ビデオプレーヤー",
"PLAYER_VIDEOJS": "Video.jsプレーヤー",
"PLAYER_ARTPLAYER": "ArtPlayer",
"PLAYER_EMBEDDED_MPV": "組み込みMPV(実験的、macOS)",
"PLAYER_MPV": "MPVプレーヤー",
"PLAYER_VLC": "VLC",
"VIDEO_PLAYER_PLACEHOLDER": "オプションを選択",
"VERSION": "バージョン",
"SAVE_CHANGES": "変更を保存",
"BACK_TO_HOME": "戻る",
"NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です",
"LATEST_VERSION": "最新バージョンを使用中です",
"STREAM_FORMAT": "ストリーム形式",
"LANGUAGE": "言語",
"SETTINGS_SAVED": "成功!設定が保存されました。",
"THEME": "視覚テーマ",
"SHOW_CAPTIONS": "字幕を表示",
"SHOW_DASHBOARD": "ダッシュボードを表示",
"SHOW_DASHBOARD_DESCRIPTION": "ワークスペースのレールにダッシュボードを表示します。無効にすると、ソースが最初のワークスペースページになります。",
"ADD_EPG_SOURCE": "EPGソースを追加",
"CLEAR_EPG_DATA": "EPGデータをクリア",
"CLEAR_EPG_DIALOG": {
"TITLE": "EPGデータをクリア",
"MESSAGE": "データベースからすべてのEPG(電子番組表)データを削除してもよろしいですか?キャッシュされたすべての番組スケジュールが削除されます。"
},
"EPG_DATA_CLEARED": "EPGデータをクリアしました",
"EPG_DATA_CLEAR_FAILED": "EPGデータのクリアに失敗しました。もう一度お試しください。",
"CLEARING_EPG_DATA": "クリア中…",
"EPG_URL_ERROR": "有効なURLではありません",
"VIDEO_PLAYER_DESCRIPTION": "デフォルトのビデオプレーヤーを選択",
"STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)",
"LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択",
"THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択",
"COVER_SIZE": "カバーサイズ",
"COVER_SIZE_DESCRIPTION": "グリッドやレールに表示されるポスターカードのサイズを調整します。大きいサイズはTVで見やすく、小さいサイズは大型モニターで1行あたりの表示数が増えます。",
"COVER_SIZE_SMALL": "小",
"COVER_SIZE_MEDIUM": "中",
"COVER_SIZE_LARGE": "大",
"STARTUP_BEHAVIOR": "起動時の表示",
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnatorを起動したときに最初に利用可能なワークスペースビューを開くか、最後に表示していたセクションを復元するかを選択します。「最初のビュー」は、ダッシュボードが有効な場合はダッシュボード、無効な場合はソースを意味します。",
"STARTUP_BEHAVIOR_FIRST_VIEW": "最初に利用可能なビュー",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "最後のビューを復元",
"SHOW_CAPTIONS_DESCRIPTION": "デフォルトで字幕を表示/非表示",
"SHOW_EXTERNAL_PLAYBACK_BAR": "再生中フッターを表示",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "デスクトップでワークスペース下部に外部プレーヤーのステータスバーを表示し続けます。",
"VERSION_DESCRIPTION": "アプリケーションの現在のバージョン",
"ABOUT_SUBTITLE": "バージョン、プロジェクトのリンク、IPTVnatorをサポートする方法。",
"EMBEDDED_MPV_NOTICE_TITLE": "組み込みMPVランタイム",
"EMBEDDED_MPV_NOTICE_DESCRIPTION": "macOS版のビルドには、実験的な組み込みMPVプレーヤー用のlibmpvおよびFFmpegライブラリを同梱できます。MPVアプリを別途インストールする必要はありません。フォールバックとして外部MPVおよびVLCも引き続き利用できます。",
"COMMUNITY": "プロジェクトとコミュニティ",
"COMMUNITY_DESCRIPTION": "リポジトリにスターを付けたり、ブログを読んだり、Telegramグループに参加して最新情報やサポートを受け取れます。",
"LINK_BLOG": "ブログ",
"LINK_GITHUB_DESCRIPTION": "リポジトリにスターを付ける、Issueを開く、コントリビュートする",
"LINK_BLOG_DESCRIPTION": "リリースノート、ヒント、プロジェクトの最新情報",
"LINK_TELEGRAM_DESCRIPTION": "お知らせ、コミュニティ、サポート",
"SUPPORT_TITLE": "プロジェクトをサポート",
"SUPPORT_DESCRIPTION": "IPTVnatorはオープンに開発されている無料のオープンソースソフトウェアです。日々の利用に役立っているなら、わずかなご支援がプロジェクトの継続と今後の方向性を後押しします。ありがとうございます。",
"SUPPORT_SPONSORS_HINT": "定期または1回限り",
"SUPPORT_KOFI_HINT": "コーヒーを奢る",
"EPG_NOTE": "現在、EPGはIPTVnatorのelectronベース版でのみ利用可能です。",
"EPG_NOTE_URL_TEXT": "こちらをチェック",
"IMPORT_EXPORT_DATA": "プレイリストのインポート/エクスポート",
"IMPORT_EXPORT_DATA_DESCRIPTION": "アプリケーション内で使用されているJSONフォーマットでプレイリストをエクスポートまたはインポート。",
"IMPORT_DATA": "インポート",
"EXPORT_DATA": "エクスポート",
"EXPORTING_DATA": "エクスポート中…",
"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.",
@@ -180,52 +320,123 @@
"MPV_PLAYER_PATH_LABEL": "mpvのパス",
"MPV_PLAYER_PATH_DESCRIPTION": "システム内のmpvプレーヤーのパスを設定します。$PATHで見つかったものの代わりに、指定されたmpvバイナリのパスを使用します。",
"MPV_PLAYER_PATH": "mpvプレーヤーのパス",
"MPV_REUSE_INSTANCE_LABEL": "MPVインスタンスを再利用",
"MPV_REUSE_INSTANCE_DESCRIPTION": "有効にすると、異なるストリームの再生に同じMPVウィンドウが再利用されます。無効にすると、各ストリームが新しいMPVウィンドウで開きます。",
"VLC_PLAYER_PATH_LABEL": "VLCのパス",
"VLC_PLAYER_PATH_DESCRIPTION": "システム内のVLCプレーヤーのパスを設定します。システムのデフォルトパスの代わりに、指定されたVLCバイナリのパスを使用します。",
"VLC_PLAYER_PATH": "VLCプレーヤーのパス",
"VLC_REUSE_INSTANCE_LABEL": "VLCインスタンスを再利用",
"VLC_REUSE_INSTANCE_DESCRIPTION": "有効にすると、異なるストリームの再生に同じVLCウィンドウが再利用されます。無効にすると、各ストリームが新しいVLCウィンドウで開きます。",
"REMOTE_CONTROL": "リモートコントロール",
"REMOTE_CONTROL_DESCRIPTION": "アプリケーションのリモートコントロールを有効にします。",
"REMOTE_CONTROL_PORT": "リモートコントロールポート",
"REMOTE_CONTROL_PORT_DESCRIPTION": "アプリケーションのリモートコントロールに使用されるポート。デフォルトではポート3000が使用されます。",
"REMOTE_CONTROL_URL": "リモートコントロールURL",
"REMOTE_CONTROL_URL_DESCRIPTION": "同じネットワーク上の別デバイスからリモートコントロールにアクセスします。",
"SHOW_QR_CODE": "QRコードを表示",
"STATUS_ENABLED": "有効",
"STATUS_DISABLED": "無効",
"SOURCE_SINGULAR": "ソース",
"SOURCE_PLURAL": "ソース",
"PORT_RANGE_LABEL": "ポート(1〜65535)",
"PORT_REQUIRED": "ポートは必須です",
"PORT_MIN": "ポートは1以上である必要があります",
"PORT_MAX": "ポートは65535以下である必要があります",
"PORT_PATTERN": "数字のみ入力できます",
"REMOVE_ALL_IN_PROGRESS": "Deleting playlist data…",
"REMOVE_ALL_PROGRESS": "Deleting playlist data… {{current}} / {{total}}",
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again.",
"EPG_EMPTY_TITLE": "No EPG sources yet",
"EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.",
"BACKUP": "Backup",
"BACKUP_DESCRIPTION": "Export your library as a snapshot, or restore from a previous backup.",
"RESET": "Reset",
"RESET_DESCRIPTION": "Permanently delete content stored in this app.",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset"
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "ダークテーマ",
"LIGHT_THEME": "ライトテーマ",
"SYSTEM_THEME": "システムテーマ"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "チャンネル一覧を非表示",
"SHOW_CHANNELS_LIST": "チャンネル一覧を表示",
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "チャンネルを検索",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
"ALL_CHANNELS": "すべてのチャンネル",
"GROUPS": "グループ",
"MANAGE_GROUPS": "グループを管理",
"MANAGE_GROUPS_HINT": "グループを再表示するには「グループを管理」を使用してください。",
"NO_VISIBLE_GROUPS": "表示中のグループがありません",
"SEARCH_GROUPS": "グループを検索",
"UNGROUPED": "グループなし",
"UNNAMED_CHANNEL": "名前のないチャンネル",
"FAVORITES": "お気に入り",
"NO_FAVORITES": "まだお気に入りがありません",
"USE_STAR_TO_FAVORITE": "'スター'アイコンを使用してチャンネルをお気に入りに追加してください。",
"REMOVE_FAVORITE": "お気に入りから削除",
"ADD_FAVORITE": "お気に入りに追加",
"SHOW_DETAILS": "チャンネルの詳細を表示",
"FAVORITES_UPDATED": "お気に入りが更新されました!",
"SELECT_CHANNEL_PLAYBACK": "再生を開始するチャンネルを選択してください",
"SORT_BY": "並び替え",
"SORT_BY_DATE": "追加日で並び替え(新しい順)",
"SORT_BY_RATING": "評価で並び替え",
"SORT_BY_NAME": "名前で並び替え(A-Z)"
"SORT_BY_NAME": "名前で並び替え(A-Z)",
"LOAD_MORE": "さらに読み込む",
"REMAINING": "残り",
"LOADING": "チャンネルを読み込み中...",
"LOADING_MORE": "さらに読み込み中...",
"NO_SEARCH_RESULTS": "チャンネルが見つかりません",
"NO_CHANNELS_FOUND": "チャンネルが見つかりません",
"NO_GROUPS_FOUND": "グループが見つかりません",
"TRY_DIFFERENT_SEARCH": "別の検索キーワードをお試しください",
"DETAILS_DIALOG": {
"TITLE": "チャンネルの詳細",
"DESCRIPTION": "プレイリストファイルから解析された読み取り専用のメタデータです。",
"SUMMARY": "概要",
"TVG": "TVGメタデータ",
"CATCHUP": "Catchup",
"HTTP": "HTTPヘッダー",
"NAME": "名前",
"CHANNEL_ID": "チャンネルID",
"STREAM_URL": "ストリームURL",
"GROUP": "グループ",
"RADIO": "ラジオ",
"CATCHUP_AVAILABLE": "Catchup利用可能",
"WINDOW": "アーカイブ期間",
"PLAYBACK_SUPPORTED": "再生対応",
"EPG_PARAMS": "EPGパラメーター",
"TIMESHIFT": "Timeshift",
"TVG_ID": "TVG ID",
"TVG_NAME": "TVG name",
"TVG_URL": "TVG URL",
"TVG_LOGO": "TVG logo",
"TVG_REC": "TVG rec",
"CATCHUP_TYPE": "Catchupタイプ",
"CATCHUP_SOURCE": "Catchupソース",
"CATCHUP_DAYS": "Catchup日数",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "未設定",
"NOT_AVAILABLE": "利用不可",
"DAYS_ONE": "{{count}}日",
"DAYS_OTHER": "{{count}}日"
},
"GROUP_MANAGEMENT": {
"TITLE": "グループを管理",
"SELECTED": "選択済み",
"SELECT_ALL": "すべて選択",
"DESELECT_ALL": "すべて解除",
"SEARCH_PLACEHOLDER": "グループを検索...",
"NO_RESULTS": "一致するグループが見つかりません",
"SAVE": "保存"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "チャンネルリストを開く",
"TOGGLE_FAVORITE_FLAG": "お気に入りフラグを切り替え",
"OPEN_EPG_LIST": "EPGリストを開く",
"OPEN_MULTI_EPG": "マルチEPGビューを開く"
"OPEN_MULTI_EPG": "マルチEPGビューを開く",
"SHOW_INFO": "番組情報を表示(Iキー)"
},
"EPG": {
"TITLE": "電子番組表",
@@ -234,12 +445,16 @@
"LIVE_NOW": "現在放送中",
"LIVE_STREAM": "ライブストリーム",
"TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生",
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "アーカイブ再生は利用できません",
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "このプロバイダーはチャンネルの番組履歴を提供していますが、アーカイブ/Catchup再生には対応していません。",
"EPG_NOT_AVAILABLE_DATE": "申し訳ありません。選択した日付のEPGは利用できません",
"NO_GUIDE_HINT": "別の日付を試すか、プレイリストを更新してください。",
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "申し訳ありません。このチャンネルのEPGは利用できません。",
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "アプリの設定でEPGリストのURLを追加/変更してください。",
"FETCH_EPG": "EPGデータを取得中...",
"ERROR": "申し訳ありません。EPGを読み込めませんでした。",
"DOWNLOAD_SUCCESS": "EPGが正常にダウンロードされました。",
"NO_PROGRAM_INFO": "番組情報はありません",
"PROGRAM_DIALOG": {
"PROGRAM_DETAILS": "番組の詳細",
"TITLE": "タイトル",
@@ -247,9 +462,23 @@
"PARENTAL_RATING_SYSTEM": "テレビ番組レーティングシステム",
"DESCRIPTION": "説明",
"LANGUAGE": "言語",
"SHOW_PROGRAM_DETAILS": "この番組の詳細を表示"
"SHOW_PROGRAM_DETAILS": "この番組の詳細を表示",
"EPISODE": "エピソード",
"DURATION": "長さ"
},
"FETCH_SUCCESS": "EPGの取得に成功しました"
"FETCH_SUCCESS": "EPGの取得に成功しました",
"IMPORT_PROGRESS": "EPGインポート",
"CHANNELS": "チャンネル",
"PROGRAMS": "番組",
"LOADING": "読み込み中",
"QUEUED": "待機中",
"TOTAL_CHANNELS": "総チャンネル数",
"TOTAL_PROGRAMS": "総番組数",
"UP_TO_DATE": "EPGは最新です。同期は不要です",
"RETRY": "再試行",
"CURRENT_PROGRAM": "Current program",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -296,6 +525,7 @@
"REFRESH": "更新",
"XTREAM": {
"PLAY": "再生",
"OPENING_PLAYER": "プレーヤーを起動中...",
"NAME": "名前",
"RELEASE_DATE": "公開日",
"GENRE": "ジャンル",
@@ -312,7 +542,59 @@
"YEAR": "年",
"AGE": "年齢",
"YOUTUBE_TRAILER": "YouTubeトレーラー",
"EPISODE": "エピソード"
"EPISODE": "エピソード",
"RESUME": "続きから再生",
"RESTART": "最初から再生",
"CONTINUE_WATCHING": "視聴を再開",
"WATCHED": "視聴済み",
"IN_PROGRESS": "視聴中",
"MARK_WATCHED": "視聴済みにする",
"MARK_UNWATCHED": "未視聴にする",
"EPISODES_WATCHED": "{{count}} / {{total}}エピソード視聴済み",
"DETAIL_FALLBACK": {
"STATUS": "ポータルのメタデータは利用できません",
"NOTE": "このポータルから拡張メタデータは提供されませんでした。",
"STREAM_ID": "ストリームID",
"CATEGORY": "カテゴリー",
"CONTAINER": "形式",
"SOURCE_TITLE": "ソースタイトル"
},
"ACCOUNT_INFO": {
"TITLE": "アカウント情報",
"USER_INFO": "ユーザー情報",
"STATUS": "ステータス",
"USERNAME": "ユーザー名",
"ACTIVE_CONNECTIONS": "アクティブな接続数",
"CREATED": "作成日",
"EXPIRES": "有効期限",
"TRIAL_ACCOUNT": "トライアルアカウント",
"YES": "はい",
"NO": "いいえ",
"ALLOWED_FORMATS": "許可された形式",
"SERVER_INFO": "サーバー情報",
"LIVE_TV": "ライブTV",
"MOVIES": "映画",
"TV_SERIES": "TVシリーズ",
"URL": "URL",
"PROTOCOL": "プロトコル",
"TIMEZONE": "タイムゾーン",
"SERVER_TIME": "サーバー時刻",
"PORTS": "ポート",
"HTTP_PORT": "HTTP",
"HTTPS_PORT": "HTTPS",
"RTMP_PORT": "RTMP"
},
"CATEGORY_MANAGEMENT": {
"TITLE": "カテゴリーを管理",
"LOADING": "カテゴリーを読み込み中...",
"SELECTED": "選択済み",
"SELECT_ALL": "すべて選択",
"DESELECT_ALL": "すべて解除",
"SEARCH_PLACEHOLDER": "カテゴリーを検索...",
"NO_RESULTS": "一致するカテゴリーが見つかりません",
"NO_CATEGORIES": "利用可能なカテゴリーはありません",
"SAVE": "保存"
}
},
"PORTALS": {
"ALL_CATEGORIES": "すべてのカテゴリー",
@@ -321,11 +603,47 @@
"REMOVE_FROM_FAVORITES": "お気に入りから削除",
"REMOVED_FROM_FAVORITES": "お気に入りから削除されました",
"ADDED_TO_FAVORITES": "お気に入りに追加されました",
"FAVORITES": "お気に入り",
"RECENTLY_VIEWED": "最近表示した項目",
"THIS_PLAYLIST": "このプレイリスト",
"ALL_PLAYLISTS": "すべてのプレイリスト",
"LIVE_TV": "ライブTV",
"MOVIES": "映画",
"SERIES": "シリーズ",
"SEARCH": "検索",
"NO_FAVORITES": "まだお気に入りがありません",
"NO_RECENT_ITEMS": "最近表示した項目はありません",
"REMOVE_FROM_RECENT": "最近表示した項目から削除",
"BROWSE_ALL_MOVIES": "すべての映画を表示",
"BROWSE_ALL_SERIES": "すべてのシリーズを表示",
"BROWSE_ALL_LIVE": "すべてのライブTVを表示",
"SIDEBAR": {
"MAIN": "メイン",
"MOVIES": "映画",
"LIVE_TV": "ライブTV",
"SERIES": "シリーズ",
"LIBRARY": "ライブラリ",
"SEARCH": "検索",
"RECENT": "最近",
"RECENTLY_ADDED": "最近追加した項目",
"FAVORITES": "お気に入り",
"HOME": "ホーム",
"PLAYLIST_INFO": "プレイリスト情報",
"ACCOUNT_INFO": "アカウント情報"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
"TITLE": "インポートをキャンセルしました",
"DESCRIPTION": "プレイリストのインポートをキャンセルしました。このソースの読み込みを続けるには再試行してください。"
},
"ACCOUNT_EXPIRED": {
"TITLE": "アカウントの有効期限が切れています",
"DESCRIPTION": "このアカウントの有効期限が切れています。プレイリスト設定で認証情報を確認するか、削除してください。"
},
"ACCOUNT_INACTIVE": {
"TITLE": "アカウントが無効です",
"DESCRIPTION": "このアカウントは無効です。プレイリスト設定で認証情報を確認するか、削除してください。"
},
"UNAUTHORIZED": {
"TITLE": "未認証",
"DESCRIPTION": "プレイリスト設定で認証情報を確認するか、削除してください。"
@@ -334,6 +652,10 @@
"TITLE": "ポータルが見つかりません",
"DESCRIPTION": "プレイリスト設定で認証情報とポータルURLを確認するか、削除してください。"
},
"PORTAL_UNAVAILABLE": {
"TITLE": "ポータルを利用できません",
"DESCRIPTION": "ポータルに接続できませんでした。サーバーURLを確認するか、ソースが復旧したら再試行してください。"
},
"EMPTY_CATEGORY": {
"TITLE": "空のカテゴリー",
"DESCRIPTION": "このカテゴリーにはコンテンツがありません。別のカテゴリーを選択するかコンテンツタイプを変更してください"
@@ -342,30 +664,173 @@
"TITLE": "問題が発生しました",
"DESCRIPTION": "不明なエラー。後でもう一度お試しください"
},
"NO_CATEGORY_SELECTED": {
"TITLE": "カテゴリーが選択されていません",
"DESCRIPTION": "コンテンツを表示するにはカテゴリーを選択してください"
},
"PLAYLIST_SETTINGS": "プレイリスト設定",
"HOME": "ホーム",
"DELETE": "削除"
},
"EMPTY_LIST_VIEW": {
"TITLE": "結果が見つかりません",
"NO_SEARCH_RESULTS": "何も見つかりませんでした。検索リクエストを変更してみてください"
},
"SEARCH_VIEW": {
"INITIAL_TITLE": "コンテンツを検索",
"INITIAL_DESCRIPTION": "ライブTV、映画、シリーズを横断して検索するには3文字以上を入力してください。",
"RESULTS_FOUND": "{{count}}件の結果が見つかりました",
"NO_RESULTS_FOR": "「{{term}}」に一致する結果は見つかりませんでした",
"GROUP_BY_PLAYLIST": "プレイリストごとに結果をグループ化",
"XTREAM_ONLY_NOTE": "グローバル検索は現在Xtreamプレイリストでのみ利用可能です。",
"VISIBLE_CATEGORIES_ONLY": "表示中のカテゴリーのみ"
},
"STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました",
"COPY_STREAM_URL": "ストリームURLをコピー",
"SEARCH_VIEW": {
"RESULTS_FOUND": "{{count}}件の結果が見つかりました",
"NO_RESULTS_FOR": "「{{term}}」に一致する結果は見つかりませんでした"
}
"SELECT_CATEGORY": "カテゴリーを選択",
"PAGE": "ページ",
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
"PLAYBACK_ERROR": "再生URLの取得に失敗しました",
"LOADING_SEASONS": "シーズンを読み込み中...",
"NO_EPISODES": "利用可能なエピソードはありません",
"ALL_RADIO": "すべてのラジオ"
},
"INFORMATION": "情報",
"DOWNLOADS": {
"TITLE": "ダウンロード",
"DOWNLOAD": "ダウンロード",
"PLAY_LOCAL": "ローカル再生",
"EMPTY": "ダウンロードはまだありません",
"EMPTY_HINT": "映画やエピソードをダウンロードしてオフラインで視聴できます",
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term",
"NOT_AVAILABLE": "ダウンロードは利用できません",
"DESKTOP_ONLY": "この機能はデスクトップアプリでのみ利用できます",
"NO_FOLDER": "ダウンロードフォルダが選択されていません",
"CHANGE_FOLDER": "フォルダを変更",
"CLEAR_COMPLETED": "完了済みをクリア",
"CLEAR_COMPLETED_DIALOG": {
"TITLE": "完了済みダウンロードをクリア",
"MESSAGE": "現在のダウンロード一覧から、完了・失敗・キャンセルされた項目を削除します。すでにディスクにダウンロードされたファイルは削除されません。"
},
"SOURCE_PLAYLIST_MISSING": "ソースのプレイリストが削除されたため、このダウンロードはライブラリで開けなくなりました。",
"FILE_NOT_FOUND": "このダウンロードファイルはディスク上で見つかりませんでした。",
"FILE_ACTION_ERROR": "ファイル操作を完了できませんでした。",
"CANCEL": "キャンセル",
"RETRY": "再試行",
"REMOVE": "削除",
"PLAY": "再生",
"REVEAL": "フォルダで表示",
"COPY_URL": "ダウンロードURLをコピー",
"URL_COPIED": "ダウンロードURLをクリップボードにコピーしました",
"URL_COPY_FAILED": "URLをクリップボードにコピーできませんでした",
"STATUS": {
"QUEUED": "待機中",
"DOWNLOADING": "ダウンロード中",
"COMPLETED": "完了",
"FAILED": "失敗",
"CANCELED": "キャンセル済み"
}
},
"REMOTE_CONTROL": {
"TITLE": "リモートコントロール",
"SUBTITLE": "IPTVプレーヤーをリモートで操作",
"UP_CHANNEL": "チャンネルアップ",
"DOWN_CHANNEL": "チャンネルダウン",
"HEADER": "IPTVNatorリモートコントロール",
"FOOTER": "IPTVNatorリモートコントロール"
},
"WORKSPACE": {
"FAVORITES_ONLY": "お気に入りのみ",
"SORT_CUSTOM": "カスタム順",
"SORT_DATE_DESC": "追加日(新しい順)",
"SORT_DATE_ASC": "追加日(古い順)",
"SORT_NAME_ASC": "名前 A-Z",
"SORT_NAME_DESC": "名前 Z-A",
"SORT_LABEL": "並び替え:",
"DASHBOARD": {
"CONTINUE_WATCHING": "視聴を再開",
"UNTITLED_SOURCE": "無題のソース",
"GLOBAL_FAVORITES": "グローバルお気に入り",
"REMOVE_FROM_HISTORY": "履歴から削除",
"RECENT_SOURCES": "最近使用したソース",
"MANAGE_ALL": "すべて管理",
"RECENTLY_WATCHED": "最近視聴した項目",
"XTREAM_RECENTLY_ADDED": "Xtreamに最近追加された項目",
"NO_HISTORY": "このフィルターに該当する項目はありません。",
"NO_HISTORY_HINT": "プレイリストからコンテンツの再生を開始すると、視聴履歴がここに表示されます。",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U",
"STALKER_PORTAL": "Stalkerポータル",
"PROVIDER": "プロバイダー",
"TYPE_LIVE": "ライブ",
"TYPE_MOVIE": "映画",
"TYPE_SERIES": "シリーズ",
"NOT_YET_SYNCED": "未同期"
},
"SHELL": {
"BRAND_ALT": "IPTVnator",
"RAIL_DASHBOARD": "ダッシュボード",
"OPEN_DASHBOARD": "ダッシュボードを開く",
"RAIL_SOURCES": "ソース",
"OPEN_SOURCES": "ソースを開く",
"RAIL_GLOBAL_FAVORITES": "お気に入り",
"RAIL_GLOBAL_RECENT": "最近表示した項目",
"RAIL_SETTINGS": "設定",
"OPEN_SETTINGS": "設定を開く",
"RAIL_LIBRARY": "ライブラリ",
"RAIL_MOVIES": "映画",
"RAIL_LIVE": "ライブTV",
"RAIL_SERIES": "シリーズ",
"RAIL_RECENTLY_ADDED": "最近追加した項目",
"RAIL_SEARCH": "詳細検索",
"RAIL_RECENT": "最近表示した項目",
"RAIL_FAVORITES": "お気に入り",
"RAIL_DOWNLOADS": "ダウンロード",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"RAIL_ALL_CHANNELS": "すべてのチャンネル",
"RAIL_GROUPS": "グループ",
"RAIL_CONTEXT_ACTIONS": "セクションのアクション",
"SEARCH_LABEL": "検索",
"SEARCH_SOURCES_PLACEHOLDER": "ソースを検索(すべてのプレイリスト)...",
"SEARCH_PLAYLIST_PLACEHOLDER": "このプレイリスト内を検索...",
"SEARCH_SECTION_PLACEHOLDER": "このセクション内を検索...",
"FILTER_SECTION_PLACEHOLDER": "このセクションをフィルター...",
"SEARCH_STATUS_LOADED_ONLY": "読み込み済みチャンネルのみ",
"COMMAND_PALETTE_SHORTCUT": "コマンドパレット(Ctrl/Cmd+K)",
"OPEN_COMMAND_PALETTE": "コマンドパレットを開く",
"ADD_PLAYLIST_ALL": "プレイリストを追加",
"ADD_PLAYLIST": "プレイリストを追加",
"OPEN_DOWNLOADS": "ダウンロードを開く",
"OPEN_CONTEXT_ACTIONS": "セクションのアクションを開く",
"NO_SECTION_ACTIONS": "このセクションには利用可能なアクションがありません",
"PLAYLIST_INFO": "プレイリスト情報",
"ACCOUNT_INFO": "アカウント情報",
"REFRESH_PLAYLIST": "プレイリストを更新",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "完了済みダウンロードをクリア(すべてのプレイリスト)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "すべてのプレイリストの完了済みダウンロードをクリア",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "完了済みダウンロードをクリア(このプレイリスト)",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST_ARIA": "このプレイリストの完了済みダウンロードをクリア",
"CLEAR_RECENTLY_VIEWED_SECTION": "最近表示した項目をクリア",
"CLEAR_RECENTLY_VIEWED_SECTION_ARIA": "このセクションの最近表示した項目をクリア",
"CLEAR_RECENTLY_VIEWED_TYPE": "最近表示した{{type}}をクリア",
"CLEAR_RECENTLY_VIEWED_DIALOG_TITLE": "最近表示した{{type}}をクリアしますか?",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST": "このプレイリストのすべての{{type}}項目が、最近表示した履歴から削除されます。この操作は取り消せません。",
"CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL": "すべてのプレイリストにわたるすべての{{type}}項目が、最近表示した履歴から削除されます。この操作は取り消せません。",
"CLEAR_FAVORITES_TYPE": "{{type}}のお気に入りをクリア",
"CLEAR_FAVORITES_DIALOG_TITLE": "{{type}}のお気に入りをすべて削除しますか?",
"CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST": "このプレイリストの{{type}}のお気に入りがすべて削除されます。この操作は取り消せません。",
"CLEAR_FAVORITES_DIALOG_MESSAGE_ALL": "すべてのプレイリストにわたる{{type}}のお気に入りがすべて削除されます。この操作は取り消せません。",
"XTREAM_CODE": "Xtream Code",
"STALKER_PORTAL": "Stalkerポータル",
"CHANNELS_COUNT": "{{count}}チャンネル",
"NO_SOURCES_AVAILABLE": "利用可能なソースがありません",
"ONE_SOURCE_AVAILABLE": "1件のソースが利用可能",
"SOURCES_AVAILABLE": "{{count}}件のソースが利用可能",
"GO_TO_DASHBOARD": "Back to dashboard",
"GO_TO_SOURCES": "Go to Sources",
"DROP_OVERLAY_TITLE": "Drop to add playlist",
@@ -375,21 +840,129 @@
"DROP_OVERLAY_REJECTED_EMPTY": "That file is empty",
"DROP_OVERLAY_REJECTED_READ_ERROR": "Could not read that file",
"DROP_IMPORT_SUCCESS": "Importing \"{{title}}\"…",
"XTREAM_IMPORT_TITLE": "プレイリストを同期中",
"XTREAM_IMPORT_REMOTE_BADGE": "リモートソース",
"XTREAM_IMPORT_LOCAL_BADGE": "ローカルライブラリ",
"XTREAM_IMPORT_DETAIL_REMOTE": "プロバイダーからの応答を待っています。",
"XTREAM_IMPORT_DETAIL_LOCAL": "次回のためにプレイリスト切り替えを高速化する準備をしています。",
"XTREAM_IMPORT_PREPARING": "ローカルライブラリを準備中...",
"XTREAM_IMPORT_LOADING": "ソースからプレイリストデータを取得中...",
"XTREAM_IMPORT_SAVING": "プレイリストデータをローカルに保存中...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "ローカルライブラリからお気に入りを復元中...",
"XTREAM_IMPORT_RESTORING_RECENT": "ローカルライブラリから最近表示した項目を復元中...",
"XTREAM_IMPORT_PROGRESS": "{{type}}インポート済み:{{current}} / {{total}}",
"XTREAM_IMPORT_CANCEL": "同期を停止",
"XTREAM_IMPORT_CANCELLING": "同期を停止中...",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "すべてのXtreamプレイリストを検索",
"GLOBAL_SEARCH_DESCRIPTION": "グローバル検索オーバーレイを開く",
"PLAYLIST_SEARCH_LABEL": "このプレイリストを詳細検索",
"PLAYLIST_SEARCH_DESCRIPTION": "プレイリストの詳細検索を開く",
"OPEN_GLOBAL_FAVORITES_LABEL": "グローバルお気に入りを開く",
"OPEN_GLOBAL_FAVORITES_DESCRIPTION": "集約されたお気に入りに移動",
"OPEN_DOWNLOADS_LABEL": "ダウンロードを開く",
"OPEN_DOWNLOADS_DESCRIPTION": "ダウンロードビューに移動",
"OPEN_GLOBAL_RECENT_LABEL": "最近表示した項目を開く",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "グローバルの最近表示した項目ページを開く",
"OPEN_SETTINGS_LABEL": "設定を開く",
"OPEN_SETTINGS_DESCRIPTION": "アプリケーション設定に移動",
"OPEN_SOURCES_LABEL": "ソースを管理",
"OPEN_SOURCES_DESCRIPTION": "ソース管理に移動",
"OPEN_DASHBOARD_LABEL": "ダッシュボードを開く",
"OPEN_DASHBOARD_DESCRIPTION": "ダッシュボードに移動",
"ADD_PLAYLIST_LABEL": "新しいソースを追加",
"ADD_PLAYLIST_DESCRIPTION": "プレイリスト追加ダイアログを開く",
"ADD_PLAYLIST_M3U_LABEL": "Add M3U playlist",
"ADD_PLAYLIST_M3U_DESCRIPTION": "Open add dialog on the M3U file or URL tab",
"ADD_PLAYLIST_XTREAM_LABEL": "Add Xtream Codes playlist",
"ADD_PLAYLIST_XTREAM_DESCRIPTION": "Open add dialog on the Xtream Codes tab",
"ADD_PLAYLIST_STALKER_LABEL": "Add Stalker portal",
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab"
"ADD_PLAYLIST_STALKER_DESCRIPTION": "Open add dialog on the Stalker Portal tab",
"REFRESH_PLAYLIST_LABEL": "プレイリストを更新",
"REFRESH_PLAYLIST_DESCRIPTION": "リモートソースからデータを再同期",
"PLAYLIST_INFO_LABEL": "プレイリストの詳細",
"PLAYLIST_INFO_DESCRIPTION": "現在のプレイリスト情報を表示",
"ACCOUNT_INFO_LABEL": "アカウント情報",
"ACCOUNT_INFO_DESCRIPTION": "Xtreamサーバーのアカウント詳細を表示",
"OPEN_VIEW_DESCRIPTION": "{{view}}に移動",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "このビューに表示中の{{type}}項目をすべてクリア",
"OPEN_MULTI_EPG_DESCRIPTION": "マルチチャンネルEPGビューを開く",
"GO_TO_VOD_LABEL": "映画",
"GO_TO_VOD_DESCRIPTION": "映画セクションに移動",
"GO_TO_LIVE_LABEL": "ライブTV",
"GO_TO_LIVE_DESCRIPTION": "ライブTVセクションに移動",
"GO_TO_SERIES_LABEL": "シリーズ",
"GO_TO_SERIES_DESCRIPTION": "シリーズセクションに移動",
"SWITCH_PLAYER_LABEL": "プレーヤーを{{name}}に切り替え",
"SWITCH_PLAYER_DESCRIPTION": "{{name}}をアクティブなビデオプレーヤーに設定",
"SWITCH_PLAYER_FEEDBACK": "プレーヤーを{{name}}に設定しました"
},
"EXTERNAL_PLAYBACK_CLOSE": "Close player"
"XTREAM_REFRESH_TITLE": "プレイリストを更新中",
"XTREAM_REFRESH_COLLECTING_DATA": "ライブラリデータを保持しています...",
"XTREAM_REFRESH_DELETING_CONTENT": "キャッシュ済みストリームを削除しています...",
"XTREAM_REFRESH_DELETING_CATEGORIES": "キャッシュ済みカテゴリを削除しています...",
"XTREAM_REFRESH_DETAIL_LOCAL": "お気に入り、視聴履歴、非表示カテゴリ、再生進捗を同期後に復元できるよう準備しています。",
"XTREAM_REFRESH_PROGRESS": "処理済みローカルレコード: {{current}} / {{total}}",
"RAIL_RADIO": "ラジオ"
},
"SOURCES": {
"TITLE": "ソース",
"SUBTITLE": "プレイリストをフィルター・並び替え",
"TYPE": "種類",
"ALL": "すべて",
"M3U_PLAYLISTS": "M3Uプレイリスト",
"XTREAM_PLAYLISTS": "Xtreamプレイリスト",
"STALKER_PLAYLISTS": "Stalkerプレイリスト",
"ALL_PLAYLISTS": "すべてのプレイリスト",
"PLAYLIST_COUNT_ONE": "{{count}}件のプレイリスト",
"PLAYLIST_COUNT_OTHER": "{{count}}件のプレイリスト"
},
"CONTEXT": {
"CATEGORIES": "カテゴリー",
"MOVIE_CATEGORIES": "映画カテゴリー",
"SERIES_CATEGORIES": "シリーズカテゴリー",
"LIVE_CATEGORIES": "ライブカテゴリー",
"XTREAM_SYNCING_MOVIES": "映画カテゴリーを同期中...",
"XTREAM_SYNCING_SERIES": "シリーズカテゴリーを同期中...",
"XTREAM_SYNCING_LIVE": "ライブカテゴリーを同期中...",
"XTREAM_SOURCE": "Xtreamソース",
"STALKER_PORTAL": "Stalkerポータル",
"MANAGE_CATEGORIES": "カテゴリーを管理",
"LOAD_CATEGORIES_ERROR": "カテゴリーを読み込めません",
"LOAD_CATEGORIES_ERROR_HINT": "再試行するか、ポータル設定を確認してください。",
"FILTER_BY_TYPE": "種類でフィルター",
"ITEM_COUNT_ONE": "{{count}}件",
"ITEM_COUNT_OTHER": "{{count}}件",
"SEARCH": "検索",
"SEARCH_CATEGORIES": "カテゴリーを検索",
"RADIO_CATEGORIES": "ラジオカテゴリ"
},
"COMMAND_PALETTE": {
"TITLE": "コマンドパレット",
"SUBTITLE": "グローバルおよびコンテキストアクション",
"SEARCH_PLACEHOLDER": "コマンドを絞り込むには入力",
"SEARCH_LABEL": "コマンドを絞り込む",
"EMPTY": "コマンドが見つかりません",
"GROUP_GLOBAL": "グローバルアクション",
"GROUP_PLAYLIST": "このプレイリスト",
"GROUP_VIEW": "現在のビュー",
"GROUP_SECTION": "このセクション",
"GROUP_RECENT": "最近使用"
},
"GLOBAL_FAVORITES": {
"TITLE": "グローバルお気に入り",
"SUBTITLE": "すべてのプレイリストのライブTV",
"SEARCH_PLACEHOLDER": "お気に入りを検索...",
"NO_RESULTS": "結果がありません",
"NO_RESULTS_HINT": "別の検索キーワードをお試しください",
"EMPTY_TITLE": "ライブTVのお気に入りはまだありません",
"EMPTY_HINT": "M3U、Xtream、Stalkerプレイリストのライブチャンネルにスターを付けると、ここに表示されます。",
"EMPTY_PLAYER": "視聴を開始するチャンネルを選択してください",
"NO_PLAYLISTS_TITLE": "No favorites to show",
"NO_PLAYLISTS_BODY": "Add a playlist to start collecting favorites from live channels, movies, and series.",
"NO_ITEMS_TITLE": "No favorites yet",
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here."
"NO_ITEMS_BODY": "Tap the star icon on any channel, movie, or series to save it here.",
"UNKNOWN_CHANNEL": "不明"
},
"GLOBAL_RECENT": {
"NO_PLAYLISTS_TITLE": "Nothing watched yet",
@@ -397,12 +970,5 @@
"NO_ITEMS_TITLE": "Nothing watched yet",
"NO_ITEMS_BODY": "Start watching anything from your library and it will show up here."
}
},
"DOWNLOADS": {
"NO_PLAYLISTS_TITLE": "Add a playlist to start downloading",
"NO_PLAYLISTS_BODY": "Once you add a playlist, you can save movies and episodes here for offline playback.",
"NO_ITEMS_BODY": "Use the download button on any movie or episode to save it for offline playback.",
"NO_FILTER_MATCH": "No downloads match the current filter",
"NO_FILTER_MATCH_HINT": "Try a different search term"
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 443 files, more files were not shown because too many files have changed in this diff. Show more