diff --git a/.changes/settings-search.md b/.changes/settings-search.md new file mode 100644 index 000000000..137d7eff1 --- /dev/null +++ b/.changes/settings-search.md @@ -0,0 +1,10 @@ +--- +type: feature +area: settings +highlight: Search your settings +--- + +Settings are now searchable: type in the search box on the Settings page to +find any option by name, description or a common word like "dark" or "mpv", +then jump straight to it. The command palette (Ctrl/Cmd+K) finds settings +too, from anywhere in the app. diff --git a/.changes/web-initial-path-audit.md b/.changes/web-initial-path-audit.md new file mode 100644 index 000000000..f1017a28c --- /dev/null +++ b/.changes/web-initial-path-audit.md @@ -0,0 +1,8 @@ +--- +type: perf +area: web +--- + +The app now loads about 40% less code before it shows its first screen: +channel lists, program guide views, Stalker portal tools and a few dialogs +are loaded when they are first needed instead of at startup. diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md deleted file mode 100644 index 6fdb44076..000000000 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: "\U0001F41E Bug report" -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - -- PWA or Electron/Tauri application -- OS: [e.g. Linux] -- Browser [e.g. chrome, safari] -- Version [e.g. 0.16.0] -- Feature: [e.g. M3U playlist, Xtream Code, Stalker Portal] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md deleted file mode 100644 index 24405ec4f..000000000 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: "\U0001F680 Feature request" -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..94e7ddad3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,126 @@ +name: ๐Ÿž Bug report +description: Something in IPTVnator doesn't work as expected. +title: '[Bug]: ' +labels: ['bug :bug:'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! The details below help us reproduce it on the first try. + + - **A channel, movie or episode doesn't play or stops?** The **Playback problem** form asks the right questions for that. + - **A question or an idea?** Please use [Discussions](https://github.com/4gray/iptvnator/discussions) or the **Feature request** form. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I'm on the latest release (or a nightly build) and the problem still happens there. + required: true + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from everything I paste below. + required: true + - type: input + id: version + attributes: + label: IPTVnator version + description: Shown in **Settings โ†’ About**. For a nightly build, add the commit shown there too. + placeholder: 0.24.0 + validations: + required: true + - type: dropdown + id: install + attributes: + label: How do you run IPTVnator? + options: + - Windows installer (.exe) + - macOS app (.dmg or .zip) + - macOS via Homebrew + - Linux AppImage + - Linux .deb, .rpm or pacman package + - Linux Snap + - Linux Flatpak + - Arch Linux AUR (iptvnator-bin) + - Self-hosted web app (Docker) + - Built from source + validations: + required: true + - type: input + id: os + attributes: + label: Operating system and version + description: For the self-hosted web app, add the browser you use as well. + placeholder: Windows 11 24H2 ยท macOS 26.6 on Apple Silicon ยท Ubuntu 24.04 ยท Docker on Unraid + Firefox + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source type + description: Which kind of source is affected? Pick all that apply. + multiple: true + options: + - M3U playlist added by URL + - M3U playlist added from a file or text + - Xtream Codes + - Stalker portal + - Not related to a source + validations: + required: true + - type: dropdown + id: player + attributes: + label: Video player + description: The player selected in **Settings โ†’ Playback โ†’ Video player**. + options: + - HTML5 video player + - Video.js player + - ArtPlayer + - Embedded MPV (Experimental) + - Embedded MPV with the frame-copy engine + - MPV player (external) + - VLC (external) + - Not related to playback + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Did this work in an earlier version? + options: + - Yes, it worked in an earlier version + - No, it never worked for me + - I don't know + validations: + required: true + - type: input + id: last-working-version + attributes: + label: Last version where it worked + description: Only if it worked before. + placeholder: 0.23.0 + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you see, and what did you expect to happen instead? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open an Xtream Codes playlist + 2. Go to Live TV and pick a category + 3. โ€ฆ + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics, logs and screenshots + description: | + If an error screen appears during playback, click **Copy diagnostics** and paste the report here; it contains no stream URLs or credentials. + You can also drag screenshots or a short screen recording into this field. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..983d32582 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ๐Ÿ’ฌ Questions and help + url: https://github.com/4gray/iptvnator/discussions + about: Ask how to set something up, or share ideas with other users. + - name: ๐Ÿณ Self-hosting with Docker + url: https://github.com/4gray/iptvnator/blob/master/docker/README.md + about: Setup, environment variables and reverse-proxy notes for the self-hosted web app. + - name: ๐ŸŒ Website and downloads + url: https://4gray.github.io/iptvnator/ + about: Download links for every platform, FAQ and release announcements. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..087cad573 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,72 @@ +name: ๐Ÿš€ Feature request +description: Suggest a new feature or an improvement. +title: '[Feature]: ' +labels: ['enhancement :magic_wand:'] +body: + - type: markdown + attributes: + value: | + Thanks for the idea! If a similar request already exists, a ๐Ÿ‘ and your use case there help more than a new issue. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been requested yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from any examples or screenshots I share. + required: true + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Describe what you're trying to do and what gets in the way today. + validations: + required: true + - type: textarea + id: solution + attributes: + label: What would you like to happen? + description: How should it work from your point of view? Mockups and examples from other apps are welcome. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + multiple: true + options: + - Live TV and programme guide (EPG) + - Movies and series + - Playback and player controls + - Playlists and sources + - Favorites, history and downloads + - Search + - Settings, backup and sync + - Installation, updates and packaging + - Self-hosted web app (Docker) + - Other + - type: dropdown + id: source + attributes: + label: Source types + description: Which sources should this work with? + multiple: true + options: + - M3U playlists + - Xtream Codes + - Stalker portals + - All sources + - type: textarea + id: alternatives + attributes: + label: Alternatives you've considered + description: Workarounds you use today, or other ways to solve the problem. + - type: dropdown + id: contribute + attributes: + label: Would you like to help? + options: + - Yes, I'd like to open a pull request + - I can help test it + - Not right now diff --git a/.github/ISSUE_TEMPLATE/playback_problem.yml b/.github/ISSUE_TEMPLATE/playback_problem.yml new file mode 100644 index 000000000..dec23851c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/playback_problem.yml @@ -0,0 +1,150 @@ +name: โ–ถ๏ธ Playback problem +description: A channel, movie or episode doesn't start, stops, stutters, or has no picture or sound. +title: '[Playback]: ' +labels: ['bug :bug:'] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a playback problem! + + **Quick check first:** the built-in HTML5, Video.js and ArtPlayer players can't decode some codecs, for example HEVC/4K, AC3 or MPEG-2 video. + - **Desktop app:** such streams usually play with **Embedded MPV** or an external **MPV** or **VLC** (**Settings โ†’ Playback โ†’ Video player**). Whether the stream plays there tells us a lot, so please mention it below. + - **Self-hosted web app:** only the browser players are available. HLS, DASH and MPEG-TS streams may fail when the provider doesn't send CORS headers, and the browser may block `http://` streams on an `https://` page. Trying the same stream in the desktop app or in VLC helps us narrow it down. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I'm on the latest release (or a nightly build) and the problem still happens there. + required: true + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from everything I paste below. + required: true + - type: input + id: version + attributes: + label: IPTVnator version + description: Shown in **Settings โ†’ About**. For a nightly build, add the commit shown there too. + placeholder: 0.24.0 + validations: + required: true + - type: dropdown + id: install + attributes: + label: How do you run IPTVnator? + options: + - Windows installer (.exe) + - macOS app (.dmg or .zip) + - macOS via Homebrew + - Linux AppImage + - Linux .deb, .rpm or pacman package + - Linux Snap + - Linux Flatpak + - Arch Linux AUR (iptvnator-bin) + - Self-hosted web app (Docker) + - Built from source + validations: + required: true + - type: input + id: os + attributes: + label: Operating system and version + description: For the self-hosted web app, add the browser you use as well. + placeholder: Windows 11 24H2 ยท macOS 26.6 on Apple Silicon ยท Ubuntu 24.04 ยท Docker on Unraid + Firefox + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source type + options: + - M3U playlist + - Xtream Codes + - Stalker portal + validations: + required: true + - type: dropdown + id: content + attributes: + label: What were you playing? + options: + - Live TV channel + - Catch-up / archive programme + - Movie + - Series episode + - Radio station + validations: + required: true + - type: dropdown + id: player + attributes: + label: Video player + description: The player selected in **Settings โ†’ Playback โ†’ Video player**. + options: + - HTML5 video player + - Video.js player + - ArtPlayer + - Embedded MPV (Experimental) + - Embedded MPV with the frame-copy engine + - MPV player (external) + - VLC (external) + - Audio player (radio station) + validations: + required: true + - type: dropdown + id: symptoms + attributes: + label: What goes wrong? + multiple: true + options: + - It doesn't start or keeps loading + - An error message appears + - It stops after a while + - It stutters or keeps buffering + - No picture, only sound + - No sound, or the wrong audio track + - Subtitles are missing or wrong + - Seeking or resuming goes to the wrong position + - Something else + validations: + required: true + - type: dropdown + id: elsewhere + attributes: + label: Does the same stream play elsewhere? + options: + - Yes, with another player in IPTVnator + - Yes, in VLC or another app outside IPTVnator + - No, it fails everywhere I tried + - I haven't tried + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Did this work in an earlier version? + description: If it did, please name the last version that worked under **What happened?**. + options: + - Yes, it worked in an earlier version + - No, it never worked for me + - I don't know + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: | + Describe what you see and when. If it stops, roughly after how long? If it worked before, which version was the last one that worked? + Mention the stream format if you know it (HLS .m3u8, MPEG-TS .ts, DASH .mpd, MKVโ€ฆ). Please don't paste full stream URLs that contain credentials or tokens. + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics, logs and screenshots + description: | + If an error screen appears, click **Copy diagnostics** and paste the report here; it contains no stream URLs or credentials. + If playback starts, the values from **Stream info** in the player overlay help too. Screenshots and short recordings are welcome. diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index d901a20ee..e31a370fc 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1099,14 +1099,33 @@ jobs: sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22 snap connections iptvnator | awk \ '$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }' + probe_timed_out() { + # GNU timeout exits 124 when SIGTERM ends the app and 137 when -k + # escalates to SIGKILL. --verbose announces that KILL, so an app + # killed by anything else is not misreported as a timeout. + local status="$1" output="$2" + [ "${status}" -eq 124 ] && return 0 + [ "${status}" -eq 137 ] && + grep -Fq 'sending signal KILL to command' <<<"${output}" + } + # GNU timeout kills a packaged app that never exits, for example when the + # main process throws before app ready and Electron's uncaught-exception + # dialog blocks under xvfb. ELECTRON_ENABLE_LOGGING routes Chromium and + # main-process stderr into the captured output; the probe's JSON verdict is + # still a single stdout line that grep -Fx matches. set +e disconnected_probe="$( - xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 ELECTRON_ENABLE_LOGGING=1 \ + timeout --verbose -k 10 300 \ snap run iptvnator --embedded-mpv-runtime-probe 2>&1 )" disconnected_status=$? set -e printf '%s\n' "${disconnected_probe}" + if probe_timed_out "${disconnected_status}" "${disconnected_probe}"; then + echo "::error::The packaged Snap app did not exit within 300 seconds during the disconnected graphics runtime probe and was killed; inspect the probe output above for main-process startup errors." + exit 1 + fi test "${disconnected_status}" -eq 1 printf '%s\n' "${disconnected_probe}" | \ grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}' @@ -1117,27 +1136,43 @@ jobs: '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }' snap connections iptvnator | awk \ '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }' - xvfb-run -a env \ - LIBGL_ALWAYS_SOFTWARE=1 \ - IPTVNATOR_TRACE_PLAYER=1 \ - EGL_LOG_LEVEL=debug \ - LIBGL_DEBUG=verbose \ - __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ - GBM_BACKEND=/tmp/hostile-gbm \ - MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \ - LIBVA_DRIVER_NAME=/tmp/hostile-va \ - VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \ - VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \ - VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \ - VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \ - VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \ - VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \ - VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \ - XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \ - XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \ - XDG_DATA_HOME=/tmp/hostile-xdg-data-home \ - XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \ - snap run iptvnator --embedded-mpv-runtime-probe + set +e + hostile_probe="$( + xvfb-run -a env \ + LIBGL_ALWAYS_SOFTWARE=1 \ + ELECTRON_ENABLE_LOGGING=1 \ + IPTVNATOR_TRACE_PLAYER=1 \ + EGL_LOG_LEVEL=debug \ + LIBGL_DEBUG=verbose \ + __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ + GBM_BACKEND=/tmp/hostile-gbm \ + MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \ + LIBVA_DRIVER_NAME=/tmp/hostile-va \ + VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \ + VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \ + VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \ + VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \ + VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \ + VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \ + VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \ + XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \ + XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \ + XDG_DATA_HOME=/tmp/hostile-xdg-data-home \ + XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \ + timeout --verbose -k 10 300 \ + snap run iptvnator --embedded-mpv-runtime-probe 2>&1 + )" + hostile_status=$? + set -e + printf '%s\n' "${hostile_probe}" + if probe_timed_out "${hostile_status}" "${hostile_probe}"; then + echo "::error::The packaged Snap app did not exit within 300 seconds during the hostile-environment runtime probe and was killed; inspect the probe output above for main-process startup errors." + exit 1 + fi + if [ "${hostile_status}" -ne 0 ]; then + echo "::error::Snap hostile-environment runtime probe failed with status ${hostile_status}." + exit "${hostile_status}" + fi - name: Run packaged x64 frame-copy and fallback smoke if: matrix.os == 'linux' && matrix.linux_profile == 'portable' @@ -1223,26 +1258,49 @@ jobs: ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")" test "${ELF_MAGIC}" = "7f454c46" ' + probe_timed_out() { + # GNU timeout exits 124 when SIGTERM ends the app and 137 when -k + # escalates to SIGKILL. --verbose announces that KILL, so an app + # killed by anything else is not misreported as a timeout. + local status="$1" output="$2" + [ "${status}" -eq 124 ] && return 0 + [ "${status}" -eq 137 ] && + grep -Fq 'sending signal KILL to command' <<<"${output}" + } + # GNU timeout kills a packaged app that never exits, for example when the + # main process throws before app ready and Electron's uncaught-exception + # dialog blocks under xvfb. ELECTRON_ENABLE_LOGGING routes Chromium and + # main-process stderr into the captured output. set +e PROBE_OUTPUT="$( - xvfb-run -a dbus-run-session -- flatpak run \ + xvfb-run -a dbus-run-session -- timeout --verbose -k 10 300 flatpak run \ --env=LIBGL_ALWAYS_SOFTWARE=1 \ + --env=ELECTRON_ENABLE_LOGGING=1 \ com.fourgray.iptvnator \ --embedded-mpv-runtime-probe 2>&1 )" PROBE_STATUS=$? set -e + # Keep both ends of oversized output: logging can push a startup + # exception or the timeout notice past the first window. PROBE_OUTPUT_LIMIT=16384 - printf '%s\n' "${PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT}" - if [ "${#PROBE_OUTPUT}" -gt "${PROBE_OUTPUT_LIMIT}" ]; then - echo "::warning::Flatpak runtime probe output was truncated to ${PROBE_OUTPUT_LIMIT} characters." + if [ "${#PROBE_OUTPUT}" -le $((PROBE_OUTPUT_LIMIT * 2)) ]; then + printf '%s\n' "${PROBE_OUTPUT}" + else + printf '%s\n' "${PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT}" + echo "::warning::Flatpak runtime probe output was truncated to its first and last ${PROBE_OUTPUT_LIMIT} characters." + printf '%s\n' "${PROBE_OUTPUT: -PROBE_OUTPUT_LIMIT}" fi if [[ "${PROBE_OUTPUT}" == *"not an ELF file"* ]] || [[ "${PROBE_OUTPUT}" == *"Zypak needs to be called directly"* ]]; then echo "::error::Flatpak launched a wrapper instead of the Electron ELF." exit 1 fi + if probe_timed_out "${PROBE_STATUS}" "${PROBE_OUTPUT}"; then + echo "::error::The packaged Flatpak app did not exit within 300 seconds during the application runtime probe and was killed; inspect the probe output above for main-process startup errors." + exit 1 + fi if [ "${PROBE_STATUS}" -ne 0 ]; then echo "::error::Flatpak application runtime probe failed with status ${PROBE_STATUS}." exit "${PROBE_STATUS}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72be0299b..33550a61b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,6 +209,10 @@ jobs: name: Unit Tests and Typechecks runs-on: ubuntu-latest timeout-minutes: 45 + permissions: + contents: read + # The scope step lists the PR's changed files through the API. + pull-requests: read steps: - name: Checkout code @@ -247,11 +251,75 @@ jobs: - name: Check i18n drift run: pnpm run i18n:check + # A pull request whose changes cannot reach any Tier A test (docs, + # notes, other workflows, website, E2E and mock-server apps, release + # and packaging tooling, a scripts-only package.json edit) skips the + # suite. The allowlist lives in a unit-tested script; anything it + # does not know runs everything, and master always runs everything. + - name: Decide unit coverage scope + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + echo "Not a pull request; running the full suite." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # The list-files endpoint stops at 3,000 files; a truncated + # list could hide a file that needs the suite. + if [ "${CHANGED_FILES:-0}" -ge 3000 ]; then + echo "PR changes ${CHANGED_FILES} files, beyond the API listing limit; running the full suite." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ + --paginate --jq '.[] | .filename, (.previous_filename // empty)' | + node tools/coverage/unit-coverage-scope.mjs --base FETCH_HEAD --github-output + + # Jest's transform cache (TypeScript/Angular transpilation plus + # coverage instrumentation, keyed by file content) is persisted + # between runs. Only master pushes and maintainer dispatches save + # it; pull requests restore it and never write, so a PR cannot plant + # an entry that a later master run would read. + - name: Restore Jest transform cache + if: steps.scope.outputs.run == 'true' && github.event_name != 'push' + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/jest-cache + key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }} + restore-keys: | + jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}- + - name: Run Tier A unit coverage suite + if: steps.scope.outputs.run == 'true' run: pnpm run coverage:ci env: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + JEST_CACHE_DIRECTORY: ${{ runner.temp }}/jest-cache + + - name: Validate coverage tooling (suite skipped) + if: steps.scope.outputs.run != 'true' + run: pnpm run coverage:tools:test && pnpm run coverage:policy:check + + # Master pushes start from an empty cache, so the saved cache holds + # exactly the current tree and does not grow run over run. + - name: Save Jest transform cache + if: >- + steps.scope.outputs.run == 'true' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/master')) + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/jest-cache + key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }} - name: Run Tier B/C validation commands run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a @@ -260,7 +328,7 @@ jobs: NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false - name: Upload unit coverage artifact - if: always() + if: always() && steps.scope.outputs.run == 'true' uses: actions/upload-artifact@v7 with: name: unit-coverage @@ -269,7 +337,7 @@ jobs: retention-days: 14 - name: Upload unit coverage to Codecov - if: always() + if: always() && steps.scope.outputs.run == 'true' uses: codecov/codecov-action@v7 with: files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index dd4031464..d3fb6874b 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -42,17 +42,26 @@ jobs: # The sequential Electron suite is split into Playwright shards (one # runner each, split by spec file). Measured on 2026-09-26, a third of # the suite is 6-12 minutes of test time on top of 3-7 minutes of - # setup, so 30 minutes leaves room for retries on every OS. - timeout-minutes: 30 + # setup. macOS uses two shards because its runners are the scarcest: + # three macOS shards per run queued for 45-57 minutes on master. Half + # of the suite is about 15 minutes of test time on macOS, so it gets + # a longer timeout to leave room for retries. + timeout-minutes: ${{ matrix.os == 'macos-latest' && 40 || 30 }} env: IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1' NX_SKIP_NX_CACHE: true strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - shard: [1, 2, 3] - shard-total: [3] + include: + - { os: ubuntu-latest, shard: 1, shard-total: 3 } + - { os: ubuntu-latest, shard: 2, shard-total: 3 } + - { os: ubuntu-latest, shard: 3, shard-total: 3 } + - { os: macos-latest, shard: 1, shard-total: 2 } + - { os: macos-latest, shard: 2, shard-total: 2 } + - { os: windows-latest, shard: 1, shard-total: 3 } + - { os: windows-latest, shard: 2, shard-total: 3 } + - { os: windows-latest, shard: 3, shard-total: 3 } steps: - uses: actions/checkout@v7 @@ -78,8 +87,30 @@ jobs: if: matrix.shard == 1 run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts - - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps + # The suite drives Electron through Playwright's _electron API and + # never launches a Playwright browser (or records video, which + # would need Playwright's ffmpeg), so no `playwright install`. + # The Ubuntu runner image already ships Electron's shared + # libraries and xvfb; fail fast with a clear message if an image + # update ever drops one. Electron 42+ downloads its binary on the + # first `require('electron')` (previously inside + # `_electron.launch()`), so resolve it the same way first. + - name: Check Electron runtime dependencies + if: runner.os == 'Linux' + run: | + command -v xvfb-run || { echo "::error::xvfb-run is missing on the runner"; exit 1; } + node -e "require('electron')" || { echo "::error::Electron binary download failed"; exit 1; } + electron_bin="$(node -p "require('electron')")" + [ -x "$electron_bin" ] || { echo "::error::Electron binary not found at $electron_bin"; exit 1; } + ldd_status=0 + libs="$(ldd "$electron_bin")" || ldd_status=$? + missing="$(grep 'not found' <<< "$libs" || true)" + if [ -n "$missing" ]; then + echo "::error::Electron is missing shared libraries:" + echo "$missing" + exit 1 + fi + [ "$ldd_status" -eq 0 ] || { echo "::error::ldd failed on $electron_bin (exit $ldd_status)"; exit 1; } - name: Run Electron E2E Tests (Linux) if: runner.os == 'Linux' diff --git a/README.md b/README.md index fbec01873..25bcbb9d8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ The application is a cross-platform, open-source project built with Electron and - Per-playlist and global favorites, aggregated across all playlists โญ - Recently viewed / watch history -- Command palette (`Ctrl/Cmd+K`) +- Command palette (`Ctrl/Cmd+K`) that also finds and opens individual settings +- Settings search from the header search box on the Settings page **Platform** @@ -76,9 +77,9 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list. | Area | Shortcut | Action | | ----------------- | --------------------------- | ---------------------------------------------------------- | | Global | `Ctrl/Cmd+K` | Open command palette | -| Global | `Ctrl/Cmd+F` | Open global search in the desktop app | +| Global | `Ctrl/Cmd+F` | Open global search (desktop); on Settings, search settings | | Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app | -| Global | `Enter` in workspace search | Submit the current search | +| Global | `Enter` in workspace search | Submit the search; on Settings, open the best match | | Global | `F11` | Toggle app window fullscreen in the desktop app | | Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar | | Navigation | `0-9` | Select an M3U channel by number | diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 1ed31f3f8..c4b131b6b 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -254,6 +254,84 @@ test.describe('Settings', () => { ); }); + test('@settings @search @web Search settings from the header and open a result', async ({ + page, + }) => { + await openSettings(page); + // The fresh browser context has no playlists; settings search must + // still be offered. + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' + ); + await expect(search).toBeEnabled(); + + // "subtitles" is a keyword of the "Show captions" row. + await search.fill('subtitles'); + await expect(page).toHaveURL(/\/workspace\/settings\/general\?q=subtitles$/); + await expect( + page.locator('[data-test-id="settings-search-results"]') + ).toBeVisible(); + await expect(page.locator('app-settings-general-section')).toHaveCount(0); + await expect( + page.locator('[data-test-id="settings-section-matches-general"]') + ).toHaveText('1'); + + await page + .locator('[data-test-id="settings-search-result-show-captions"]') + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/general$/); + await expect(search).toHaveValue(''); + const row = page.locator('[data-setting-id="show-captions"]'); + await expect(row).toBeFocused(); + await expect(row).toHaveClass(/setting-item--revealed/); + await expect(row).not.toHaveClass(/setting-item--revealed/, { + timeout: 5000, + }); + }); + + test('@settings @search @web Enter opens the best settings match', async ({ + page, + }) => { + await openSettings(page); + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' + ); + + await search.fill('stream format'); + await search.press('Enter'); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="stream-format"]') + ).toBeFocused(); + }); + + test('@settings @search @web Command palette opens a setting from anywhere', async ({ + page, + }) => { + // The shell registers Ctrl/Cmd+K once its lazy chunk has rendered. + await expect( + page.locator('a[href$="/workspace/settings"]') + ).toBeVisible(); + await expect(page).not.toHaveURL(/\/workspace\/settings/); + await page.keyboard.press('Control+k'); + + const palette = page.locator('.workspace-command-palette-overlay'); + await expect(palette).toBeVisible(); + await palette.locator('input[type="search"]').fill('ambient'); + await expect(palette).toContainText('Settings'); + await palette + .locator('.palette-command', { hasText: /ambient/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="player-ambient-mode"]') + ).toBeFocused(); + }); + test.afterEach(async ({ page }, testInfo) => { await page.screenshot({ path: join( diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 6fb90a9a1..3186de1c7 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -21,6 +21,10 @@ const collectCoverageFrom = [ export default { ...nxPreset, ...angularEsmPreset, + // See jest.preset.js: CI persists the transform cache from this directory. + ...(process.env.JEST_CACHE_DIRECTORY + ? { cacheDirectory: process.env.JEST_CACHE_DIRECTORY } + : {}), displayName: 'web', setupFilesAfterEnv: ['/src/test-setup.ts'], coverageDirectory: '../../coverage/apps/web', diff --git a/apps/web/project.json b/apps/web/project.json index 302ea2e37..72b30b14e 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -57,8 +57,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "4.5mb", - "maximumError": "5mb" + "maximumWarning": "1.8mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", @@ -88,8 +88,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "4.5mb", - "maximumError": "5mb" + "maximumWarning": "1.8mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts index f567a2573..d92cc47d9 100644 --- a/apps/web/src/app/app-update-notification-panel.component.spec.ts +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -73,7 +73,7 @@ describe('AppUpdateNotificationPanelComponent', () => { ).not.toBeNull(); }); - it('opens release notes without dismissing the notification', () => { + it('opens release notes without dismissing the notification', async () => { statusHandler?.(availableStatus); fixture.detectChanges(); @@ -82,6 +82,9 @@ describe('AppUpdateNotificationPanelComponent', () => { '[data-test-id="app-update-notification-release-notes"]' ) as HTMLButtonElement ).click(); + // The dialog component is imported on demand (it pulls in `marked`). + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); expect(TestBed.inject(MatDialog).open).toHaveBeenCalledWith( AppUpdateReleaseNotesDialogComponent, @@ -96,6 +99,29 @@ describe('AppUpdateNotificationPanelComponent', () => { ).not.toBeNull(); }); + it('falls back to the releases page when the release notes dialog fails to load', async () => { + const open = jest.spyOn(window, 'open').mockReturnValue(null); + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + fixture.componentInstance.loadReleaseNotesDialog = () => + Promise.reject(new Error('chunk failed')); + statusHandler?.(availableStatus); + fixture.detectChanges(); + + await fixture.componentInstance.openReleaseNotes(); + + expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith( + availableStatus.manualDownloadUrl, + '_blank', + 'noreferrer' + ); + expect(error).toHaveBeenCalledTimes(1); + open.mockRestore(); + error.mockRestore(); + }); + it('starts downloading the update from the notification action', async () => { statusHandler?.(availableStatus); fixture.detectChanges(); diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts index d10b02117..a99e814ad 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -16,7 +16,6 @@ import { ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; import { AppUpdateInstallService } from './services/app-update-install.service'; -import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component'; @Component({ selector: 'app-update-notification-panel', @@ -239,8 +238,31 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy { this.unsubscribeStatus = null; } - openReleaseNotes(): void { - const latestVersion = this.status()?.latestVersion; + /** + * Loaded on demand: the dialog renders Markdown with `marked`, which this + * always-mounted panel would otherwise put on the initial path. A field so + * specs can substitute it. + */ + loadReleaseNotesDialog = () => + import('./settings/app-update-release-notes-dialog.component'); + + async openReleaseNotes(): Promise { + const status = this.status(); + const latestVersion = status?.latestVersion; + let dialogModule: Awaited< + ReturnType + >; + try { + dialogModule = await this.loadReleaseNotesDialog(); + } catch (error) { + // The same notes are on the releases page; the next click retries. + console.error('Could not load the release notes dialog:', error); + if (status?.manualDownloadUrl) { + window.open(status.manualDownloadUrl, '_blank', 'noreferrer'); + } + return; + } + const { AppUpdateReleaseNotesDialogComponent } = dialogModule; this.dialog.open(AppUpdateReleaseNotesDialogComponent, { autoFocus: false, diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 3e4dc7ad0..079fc8e9f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -21,7 +21,9 @@ import { WORKSPACE_SHELL_ACTIONS, } from '@iptvnator/workspace/shell/util'; import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; -import { WindowControlsComponent } from '@iptvnator/ui/components'; +// File-level entry: the @iptvnator/ui/components barrel would put the whole +// library (channel lists, EPG, forms, date-fns) on the initial path. +import { WindowControlsComponent } from '@iptvnator/ui/components/window-controls'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { filter, take } from 'rxjs'; import { diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 0f08a66bf..1a4a956fe 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -31,7 +31,7 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; -import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; import { DataService } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; @@ -43,7 +43,7 @@ import { PlayerService } from './services/player.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; import { shouldEnableServiceWorker } from './services/runtime-config'; -import { AppStalkerPlaylistConnectionEditorService } from './services/stalker-playlist-connection-editor.service'; +import { LazyStalkerPlaylistConnectionEditor } from './services/lazy-stalker-playlist-connection-editor'; import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service'; // AoT requires an exported function for factories @@ -157,7 +157,7 @@ export const appConfig: ApplicationConfig = { ...providePortalPlaybackPositions(), { provide: STALKER_PLAYLIST_CONNECTION_EDITOR, - useExisting: AppStalkerPlaylistConnectionEditorService, + useExisting: LazyStalkerPlaylistConnectionEditor, }, ...provideWorkspaceShellActions(), ...provideXtreamDataSource(), diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 525ff5645..dd5be4f0b 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -3,7 +3,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DialogService } from '@iptvnator/ui/components'; +import { DialogService } from '@iptvnator/ui/components/confirm-dialog'; import { DataService, SettingsStore, diff --git a/apps/web/src/app/services/external-player-info-dialog.lazy.ts b/apps/web/src/app/services/external-player-info-dialog.lazy.ts new file mode 100644 index 000000000..b5158073e --- /dev/null +++ b/apps/web/src/app/services/external-player-info-dialog.lazy.ts @@ -0,0 +1,9 @@ +/** + * Lazy boundary for PlayerService: the service imports this file dynamically, + * so the dialog and the @angular/forms it brings (via the Material checkbox) + * stay off the initial path. The boundary is a local file rather than the + * library path because other web files, such as specs, import ui-playback + * statically, which @nx/enforce-module-boundaries forbids for a library the + * project also loads dynamically. + */ +export { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog'; diff --git a/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts new file mode 100644 index 000000000..4a35313b4 --- /dev/null +++ b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { + StalkerPlaylistConnectionEditor, + StalkerPlaylistConnectionResult, +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; +import type { + PlaylistMeta, + PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; +import { LazyStalkerPlaylistConnectionEditor } from './lazy-stalker-playlist-connection-editor'; + +const resolved: StalkerPlaylistConnectionResult = { + status: 'resolved', + playlist: { _id: 'p1' } as PlaylistMetaUpdate, +}; + +@Injectable({ providedIn: 'root' }) +class FakeEditor implements StalkerPlaylistConnectionEditor { + resolveConnection = jest.fn(async () => resolved); + applyResolvedConnection = jest.fn( + async (playlist: PlaylistMetaUpdate) => playlist + ); +} + +type EditorModule = Awaited< + ReturnType +>; + +function moduleWith(editorClass: typeof FakeEditor): EditorModule { + return { + AppStalkerPlaylistConnectionEditorService: editorClass, + } as unknown as EditorModule; +} + +describe('LazyStalkerPlaylistConnectionEditor', () => { + let lazy: LazyStalkerPlaylistConnectionEditor; + + beforeEach(() => { + TestBed.configureTestingModule({}); + lazy = TestBed.inject(LazyStalkerPlaylistConnectionEditor); + }); + + it('loads nothing until an editor method is called', () => { + const load = jest.fn(async () => moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + + expect(load).not.toHaveBeenCalled(); + }); + + it('delegates both methods to the root-provided implementation, loading it once', async () => { + const load = jest.fn(async () => moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + const playlist = { _id: 'p1' } as PlaylistMeta; + const source = { _id: 'p0' } as PlaylistMeta; + const update = { _id: 'p1', title: 'Renamed' } as PlaylistMetaUpdate; + + await expect(lazy.resolveConnection(playlist, source)).resolves.toBe( + resolved + ); + await expect( + lazy.applyResolvedConnection(update, { + preserveCurrentMetadata: true, + }) + ).resolves.toBe(update); + + const editor = TestBed.inject(FakeEditor); + expect(editor.resolveConnection).toHaveBeenCalledWith(playlist, source); + expect(editor.applyResolvedConnection).toHaveBeenCalledWith(update, { + preserveCurrentMetadata: true, + }); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('retries the import after a failed chunk load', async () => { + const load = jest + .fn, []>() + .mockRejectedValueOnce(new Error('chunk failed')) + .mockResolvedValueOnce(moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + const playlist = { _id: 'p1' } as PlaylistMeta; + + await expect(lazy.resolveConnection(playlist)).rejects.toThrow( + 'chunk failed' + ); + await expect(lazy.resolveConnection(playlist)).resolves.toBe(resolved); + expect(load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts new file mode 100644 index 000000000..187a59e43 --- /dev/null +++ b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts @@ -0,0 +1,56 @@ +import { EnvironmentInjector, inject, Injectable } from '@angular/core'; +import type { + StalkerPlaylistConnectionEditor, + StalkerPlaylistConnectionResult, + StalkerResolvedConnectionApplyOptions, +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; +import type { + PlaylistMeta, + PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; + +type EditorModule = + typeof import('./stalker-playlist-connection-editor.service'); + +/** + * Registered as STALKER_PLAYLIST_CONNECTION_EDITOR in app.config. The real + * editor depends on the whole Stalker portal data layer, which would + * otherwise be evaluated before the first paint although it is only needed + * when a user edits or re-checks a Stalker source. Every editor method is + * asynchronous, so the implementation is imported on first use and resolved + * from the root injector (it is providedIn: 'root'). + */ +@Injectable({ providedIn: 'root' }) +export class LazyStalkerPlaylistConnectionEditor implements StalkerPlaylistConnectionEditor { + private readonly injector = inject(EnvironmentInjector); + private editor: Promise | null = null; + + /** The dynamic import; a field so specs can substitute it. */ + loadEditorModule: () => Promise = () => + import('./stalker-playlist-connection-editor.service'); + + async resolveConnection( + playlist: PlaylistMeta, + sourcePlaylist?: PlaylistMeta + ): Promise { + const editor = await this.resolveEditor(); + return editor.resolveConnection(playlist, sourcePlaylist); + } + + async applyResolvedConnection( + playlist: PlaylistMetaUpdate, + options?: StalkerResolvedConnectionApplyOptions + ): Promise { + const editor = await this.resolveEditor(); + return editor.applyResolvedConnection(playlist, options); + } + + private resolveEditor(): Promise { + this.editor ??= this.loadEditorModule().then((module) => + this.injector.get(module.AppStalkerPlaylistConnectionEditorService) + ); + // A failed chunk load must not poison later attempts. + this.editor.catch(() => (this.editor = null)); + return this.editor; + } +} diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index 86ab12ca8..665fd25eb 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { DataService } from '@iptvnator/services'; -import { ExternalPlayerSession, VideoPlayer } from '@iptvnator/shared/interfaces'; +import { + ExternalPlayerSession, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; import { SettingsStore } from './settings-store.service'; import { PlayerService } from './player.service'; @@ -92,6 +95,57 @@ describe('PlayerService', () => { expect(result).toEqual(session); }); + it('opens the external-player info dialog once its chunk has loaded', async () => { + settingsStore.player.mockReturnValue(VideoPlayer.MPV); + dataService.sendIpcEvent.mockResolvedValue(undefined); + + await service.openResolvedPlayback( + { streamUrl: 'https://example.com/video.mp4', title: 'Example' }, + false + ); + await new Promise((resolve) => setTimeout(resolve)); + + expect(dialog.open).toHaveBeenCalledTimes(1); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + 'OPEN_MPV_PLAYER', + expect.anything() + ); + }); + + it('still starts playback and logs when the info dialog fails to load', async () => { + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + try { + settingsStore.player.mockReturnValue(VideoPlayer.VLC); + dataService.sendIpcEvent.mockResolvedValue(undefined); + service.loadExternalPlayerInfoDialog = () => + Promise.reject(new Error('chunk failed')); + + await service.openResolvedPlayback( + { + streamUrl: 'https://example.com/video.mp4', + title: 'Example', + }, + false + ); + await new Promise((resolve) => setTimeout(resolve)); + + expect(dialog.open).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + 'OPEN_VLC_PLAYER', + expect.anything() + ); + expect(error).toHaveBeenCalledTimes(1); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + error.mockRestore(); + } + }); + it('forces external playback without changing the selected embedded player', async () => { const session: ExternalPlayerSession = { id: 'session-2', diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index a37e32b66..fa686c46e 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -1,6 +1,5 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog'; import { DataService } from '@iptvnator/services'; import { ExternalPlayerSession, @@ -70,12 +69,12 @@ export class PlayerService { if (player === VideoPlayer.MPV) { if (!hideExternalInfoDialog) { - this.dialog.open(ExternalPlayerInfoDialogComponent); + this.showExternalPlayerInfoDialog(); } return await this.openExternalPlayback(playback, 'mpv'); } else if (player === VideoPlayer.VLC) { if (!hideExternalInfoDialog) { - this.dialog.open(ExternalPlayerInfoDialogComponent); + this.showExternalPlayerInfoDialog(); } return await this.openExternalPlayback(playback, 'vlc'); } @@ -83,12 +82,34 @@ export class PlayerService { return; } + /** + * Loaded on demand: the dialog's Material checkbox brings @angular/forms, + * and this service is created with the app, so a static import would put + * both on the initial path. Playback does not wait for the dialog. + */ + private showExternalPlayerInfoDialog(): void { + this.loadExternalPlayerInfoDialog() + .then(({ ExternalPlayerInfoDialogComponent }) => + this.dialog.open(ExternalPlayerInfoDialogComponent) + ) + .catch((error: unknown) => { + // Informational only: playback is already starting. + console.error( + 'Could not load the external player info dialog:', + error + ); + }); + } + + /** The dynamic import; a field so specs can substitute it. */ + loadExternalPlayerInfoDialog = () => + import('./external-player-info-dialog.lazy'); + async openExternalPlayback( playback: ResolvedPortalPlayback, player: ExternalPlayerName ): Promise { - const ipcEvent = - player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER; + const ipcEvent = player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER; return await this.dataService.sendIpcEvent( ipcEvent, diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts index ed50c4526..0f74424cd 100644 --- a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts @@ -15,7 +15,7 @@ import { type StalkerPlaylistConnectionEditor, type StalkerPlaylistConnectionResult, type StalkerResolvedConnectionApplyOptions, -} from '@iptvnator/playlist/shared/ui'; +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; import { normalizeStalkerPortalIdentity, type Playlist, diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index 7542ce093..0d6f87c9f 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -9,7 +9,7 @@ -
+

{{ 'SETTINGS.VERSION' | translate }}

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

@@ -31,7 +31,10 @@
@if (isDesktop() && appUpdateStatus(); as updateStatus) { -
+

{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}

{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}

@@ -263,7 +266,10 @@
} -
+

{{ 'SETTINGS.TMDB_CREDITS' | translate }}

{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}

@@ -281,7 +287,10 @@
-
+

{{ 'SETTINGS.COMMUNITY' | translate }}

{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}

@@ -364,7 +373,10 @@
-
+
diff --git a/apps/web/src/app/settings/settings-backup-section.component.html b/apps/web/src/app/settings/settings-backup-section.component.html index bae147754..4c9a3ee07 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.html +++ b/apps/web/src/app/settings/settings-backup-section.component.html @@ -1,7 +1,4 @@ -
+
backup @@ -12,7 +9,7 @@
-
+

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html index 5c5657c4f..cd5ee2e0e 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.html +++ b/apps/web/src/app/settings/settings-dashboard-section.component.html @@ -1,8 +1,4 @@ -

+
dashboard @@ -13,7 +9,7 @@
-
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

@@ -29,7 +25,7 @@

-
+

{{ 'SETTINGS.DASHBOARD_HERO' | translate }}

@@ -44,7 +40,10 @@

-
+

{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}

@@ -62,11 +61,14 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate - }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION' @@ -82,11 +84,9 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate - }}

+

{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}

{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION' @@ -102,11 +102,11 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate - }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION' @@ -122,7 +122,7 @@

-
+

{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}

@@ -140,11 +140,11 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate - }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION' @@ -160,9 +160,14 @@

-
+
-

{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION' @@ -178,7 +183,10 @@

-
+

{{ diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index c8a96c1c1..b82aa1643 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -9,7 +9,10 @@

-
+

{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}

{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}

@@ -43,7 +46,10 @@
-
+

{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}

{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}

@@ -69,7 +75,10 @@
-
+

{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}

{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}

@@ -178,7 +187,7 @@
-
+

{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}

{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}

diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html index 452ed72a9..012a8f449 100644 --- a/apps/web/src/app/settings/settings-general-section.component.html +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -9,7 +9,7 @@
-
+

{{ 'SETTINGS.LANGUAGE' | translate }}

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

@@ -40,7 +40,10 @@
-
+

{{ 'SETTINGS.THEME' | translate }}

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

@@ -74,7 +77,10 @@
-
+

{{ 'SETTINGS.COVER_SIZE' | translate }}

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

@@ -108,7 +114,7 @@
-
+

{{ 'SETTINGS.COVER_TITLES' | translate }} @@ -127,7 +133,7 @@

-
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

@@ -160,7 +166,11 @@

@if (supportsStartupWindowMode()) { -
+

{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}

@@ -195,7 +205,7 @@

} -
+

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

@@ -207,7 +217,7 @@

-
+

{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}

@@ -233,7 +243,7 @@

{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}

-
+

{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index d17660ec8..3b03e57aa 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -5,6 +5,10 @@ import { Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; +import { + meetsSettingsRequirements, + SETTINGS_SECTION_DEFINITIONS, +} from '@iptvnator/workspace/shell/util/settings-search'; import { CoverSizeOption, EpgViewModeOption, @@ -164,65 +168,14 @@ export function buildSettingsSectionNavItems({ supportsEpg, supportsRemoteControl, }: SettingsSectionVisibility): 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: supportsEpg, - }, - { - id: 'dashboard', - label: 'SETTINGS.NAV_DASHBOARD', - icon: 'dashboard', - visible: true, - }, - { - // Must match the section's HTML id (`remote-control`) so the - // settings-section-scroll directive can resolve the anchor. - // Was previously '@iptvnator/ui/remote-control' (the NX lib - // name), which meant clicking the nav item silently no-op'd - // because document.getElementById of that string returned null. - id: 'remote-control', - label: 'SETTINGS.NAV_REMOTE', - icon: 'smartphone', - visible: supportsRemoteControl, - }, - { - id: 'tmdb', - label: 'SETTINGS.NAV_TMDB', - icon: 'movie', - visible: true, - }, - { - 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, - }, - ]; + const capabilities = { + epg: supportsEpg, + 'remote-control': supportsRemoteControl, + }; + return SETTINGS_SECTION_DEFINITIONS.map((section) => ({ + id: section.id, + label: section.navLabelKey, + icon: section.icon, + visible: meetsSettingsRequirements(section.requires, capabilities), + })); } diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 121bcafb2..7c93d5840 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -9,7 +9,7 @@

-
+

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

@@ -35,7 +35,7 @@
-
+

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}

@@ -67,6 +67,7 @@ @if (isWebPlayerSelected()) {
@@ -91,7 +92,11 @@
-
+

{{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }} @@ -109,7 +114,11 @@

-
+

{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }} @@ -131,6 +140,7 @@ @if (supportsFullscreenChannelPanel()) {
@@ -159,7 +169,11 @@ @if (supportsVodMultiSource() && isWebPlayerSelected()) { -
+

{{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }} @@ -181,6 +195,7 @@ @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
@@ -206,7 +221,7 @@ } @if (isDesktop()) { -
+

{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}

@@ -226,6 +241,7 @@ @if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {

@@ -262,6 +278,7 @@ @if (form().value.player === 'embedded-mpv') {
@@ -332,6 +349,7 @@
@@ -354,7 +372,11 @@
} -
+

{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}

@@ -391,7 +413,7 @@ @if ( supportsExternalPlayerPathSettings() && form().value.player === 'mpv' ) { -

+

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

@@ -418,6 +440,7 @@

@@ -449,7 +472,7 @@
-
+

{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}

@@ -465,7 +488,7 @@ @if ( supportsExternalPlayerPathSettings() && form().value.player === 'vlc' ) { -

+

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

@@ -489,6 +512,7 @@

@@ -520,7 +544,7 @@
-
+

{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}

diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html index 510c81ea4..bdead5a0d 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.html +++ b/apps/web/src/app/settings/settings-remote-control-section.component.html @@ -1,8 +1,4 @@ -

+
settings_remote @@ -13,7 +9,7 @@
-
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

@@ -27,7 +23,7 @@
@if (form().value.remoteControl === true) { -
+

{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}

@@ -76,7 +72,7 @@

@if (localIpAddresses().length > 0) { -
+

{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}

diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html index 18b4f441e..cb15ca67b 100644 --- a/apps/web/src/app/settings/settings-reset-section.component.html +++ b/apps/web/src/app/settings/settings-reset-section.component.html @@ -1,7 +1,4 @@ -

+
delete_sweep @@ -12,7 +9,10 @@
-
+

{{ 'SETTINGS.REMOVE_ALL' | translate }}

diff --git a/apps/web/src/app/settings/settings-search-registry.spec.ts b/apps/web/src/app/settings/settings-search-registry.spec.ts new file mode 100644 index 000000000..f6afbf8fa --- /dev/null +++ b/apps/web/src/app/settings/settings-search-registry.spec.ts @@ -0,0 +1,129 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { SETTINGS_SEARCH_ENTRIES } from '@iptvnator/workspace/shell/util/settings-search'; + +/** + * The settings search index is hand-maintained next to templates whose + * labels are hard-coded translate keys. This spec keeps the two in step: a + * new settings row without an index entry (or a renamed key) fails here + * instead of silently becoming unsearchable. + */ +interface TemplateRow { + file: string; + id: string | null; + labelKey: string | null; + descriptionKey: string | null; +} + +// The web jest config runs as ESM, so there is no `__dirname` here. +const SETTINGS_DIR = resolve(process.cwd(), 'apps/web/src/app/settings'); +const EN = JSON.parse( + readFileSync(join(SETTINGS_DIR, '../../assets/i18n/en.json'), 'utf8') +); + +function readTemplateRows(): TemplateRow[] { + const rows: TemplateRow[] = []; + const files = readdirSync(SETTINGS_DIR).filter((file) => + /^settings-.+-section\.component\.html$/.test(file) + ); + + for (const file of files) { + const html = readFileSync(join(SETTINGS_DIR, file), 'utf8'); + // Row containers only: `setting-item` itself, not `__meta` etc. + const starts = [ + ...html.matchAll(/]*class="setting-item(?:\s[^"]*)?"/g), + ]; + starts.forEach((match, index) => { + const end = starts[index + 1]?.index ?? html.length; + const chunk = html.slice(match.index, end); + const tag = chunk.slice(0, chunk.indexOf('>') + 1); + const meta = chunk.split('setting-item__control')[0]; + rows.push({ + file, + id: tag.match(/data-setting-id="([^"]+)"/)?.[1] ?? null, + labelKey: + chunk.match( + /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/ + )?.[1] ?? null, + descriptionKey: + meta.match( + /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/ + )?.[1] ?? null, + }); + }); + } + return rows; +} + +function translationExists(key: string): boolean { + return ( + typeof key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' + ? (node as Record)[part] + : undefined, + EN + ) === 'string' + ); +} + +describe('settings search registry', () => { + const rows = readTemplateRows(); + const titledRows = rows.filter((row) => row.labelKey !== null); + const byId = new Map(SETTINGS_SEARCH_ENTRIES.map((e) => [e.id, e])); + + it('finds the settings rows in the section templates', () => { + expect(titledRows.length).toBeGreaterThan(40); + }); + + it('anchors every titled settings row with a registered id', () => { + const missing = titledRows + .filter((row) => !row.id || !byId.has(row.id)) + .map((row) => `${row.file}: ${row.labelKey}`); + + expect(missing).toEqual([]); + }); + + it('registers every anchored row exactly once', () => { + const templateIds = rows.flatMap((row) => (row.id ? [row.id] : [])); + + expect(new Set(templateIds).size).toBe(templateIds.length); + expect(templateIds.slice().sort()).toEqual( + SETTINGS_SEARCH_ENTRIES.map((entry) => entry.id).sort() + ); + }); + + it('indexes the same title and description the row renders', () => { + const mismatches = titledRows.flatMap((row) => { + const entry = row.id ? byId.get(row.id) : undefined; + if (!entry) { + return []; + } + const problems: string[] = []; + if (entry.labelKey !== row.labelKey) { + problems.push(`${row.id}: label ${row.labelKey}`); + } + if (entry.descriptionKey !== (row.descriptionKey ?? undefined)) { + problems.push(`${row.id}: description ${row.descriptionKey}`); + } + if (!row.file.includes(`settings-${entry.section}-section`)) { + problems.push(`${row.id}: rendered in ${row.file}`); + } + return problems; + }); + + expect(mismatches).toEqual([]); + }); + + it('uses translation keys that exist in English', () => { + const missing = SETTINGS_SEARCH_ENTRIES.flatMap((entry) => + [entry.labelKey, entry.descriptionKey].filter( + (key): key is string => !!key && !translationExists(key) + ) + ); + + expect(missing).toEqual([]); + }); +}); diff --git a/apps/web/src/app/settings/settings-search-results.component.html b/apps/web/src/app/settings/settings-search-results.component.html new file mode 100644 index 000000000..d1290af20 --- /dev/null +++ b/apps/web/src/app/settings/settings-search-results.component.html @@ -0,0 +1,62 @@ +
+
+
+

+ {{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }} +

+

+ {{ + 'SETTINGS.SEARCH_RESULTS_COUNT' + | translate: { count: results().length, query: query() } + }} +

+
+
+ + @if (results().length > 0) { +
    + @for (result of results(); track result.entry.id) { +
  • + +
  • + } +
+ } @else { +
+ search_off +

{{ 'SETTINGS.SEARCH_EMPTY_HINT' | translate }}

+
+ } +
diff --git a/apps/web/src/app/settings/settings-search-results.component.scss b/apps/web/src/app/settings/settings-search-results.component.scss new file mode 100644 index 000000000..7e5f8ebd7 --- /dev/null +++ b/apps/web/src/app/settings/settings-search-results.component.scss @@ -0,0 +1,121 @@ +:host { + display: block; +} + +.settings-search-results__list { + list-style: none; + margin: 0; + padding: 0; +} + +.settings-search-result { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 56px; + padding: 12px 10px; + border: 0; + border-top: 1px solid var(--settings-item-border); + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + + &:hover { + background: var(--app-hover-overlay); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: -2px; + border-radius: 8px; + } +} + +li:first-child .settings-search-result { + border-top: 0; +} + +.settings-search-result__icon, +.settings-search-result__chevron { + flex-shrink: 0; + color: var(--settings-muted); +} + +.settings-search-result__text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.settings-search-result__label { + font-size: 0.95rem; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--app-heading-color); +} + +.settings-search-result__description { + overflow: hidden; + color: var(--settings-muted); + font-size: 0.84rem; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-search-result__section { + flex-shrink: 0; + padding: 2px 10px; + border: 1px solid var(--settings-item-border); + border-radius: 999px; + color: var(--settings-muted); + font-size: 0.78rem; + white-space: nowrap; +} + +.settings-search-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 16px; + color: var(--settings-muted); + text-align: center; + + mat-icon { + width: 32px; + height: 32px; + font-size: 32px; + } + + p { + margin: 0; + max-width: 420px; + line-height: 1.45; + } +} + +@media (max-width: 640px) { + .settings-search-result { + flex-wrap: wrap; + padding: 12px 14px; + } + + .settings-search-result__description { + white-space: normal; + } + + .settings-search-result__section { + margin-left: 38px; + } + + .settings-search-result__chevron { + display: none; + } +} diff --git a/apps/web/src/app/settings/settings-search-results.component.ts b/apps/web/src/app/settings/settings-search-results.component.ts new file mode 100644 index 000000000..3bb002d23 --- /dev/null +++ b/apps/web/src/app/settings/settings-search-results.component.ts @@ -0,0 +1,30 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + SettingsSearchEntry, + SettingsSearchResult, +} from '@iptvnator/workspace/shell/util/settings-search'; + +/** + * Ranked settings matches for the header search term, shown in place of the + * section page while a term is active. Best match first โ€” the same row the + * Enter key in the header search opens. + */ +@Component({ + selector: 'app-settings-search-results', + imports: [MatIconModule, TranslatePipe], + templateUrl: './settings-search-results.component.html', + styleUrl: './settings-search-results.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsSearchResultsComponent { + readonly query = input.required(); + readonly results = input.required(); + readonly selected = output(); +} diff --git a/apps/web/src/app/settings/settings-search.facade.ts b/apps/web/src/app/settings/settings-search.facade.ts new file mode 100644 index 000000000..5d8c2ce55 --- /dev/null +++ b/apps/web/src/app/settings/settings-search.facade.ts @@ -0,0 +1,178 @@ +import { + afterNextRender, + computed, + DestroyRef, + effect, + ElementRef, + inject, + Injectable, + Injector, + Signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { + SettingsRevealRequest, + SettingsSearchEntry, + SettingsSearchService, +} from '@iptvnator/workspace/shell/util/settings-search'; +import { map, startWith } from 'rxjs'; + +/** How long a revealed row keeps its highlight. */ +export const SETTINGS_REVEAL_HIGHLIGHT_MS = 2400; +export const SETTINGS_REVEALED_CLASS = 'setting-item--revealed'; + +export interface SettingsRevealBinding { + /** Section page currently rendered. */ + readonly activeSection: Signal; + /** True once the form is hydrated, so form-dependent rows exist. */ + readonly ready: Signal; +} + +/** + * Settings page side of settings search. The header search writes the term + * to the `q` query param (shell-owned); this facade turns it into ranked + * results, publishes per-section match counts to the settings navigation, + * and scrolls to and highlights a row that search or the command palette + * asked `SettingsSearchService` to reveal. + */ +@Injectable() +export class SettingsSearchFacade { + private readonly route = inject(ActivatedRoute); + private readonly settingsSearch = inject(SettingsSearchService); + private readonly settingsCtx = inject(SettingsContextService); + private readonly translate = inject(TranslateService); + private readonly host = inject(ElementRef); + private readonly injector = inject(Injector); + private highlightTimeoutId: ReturnType | null = null; + + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); + + readonly query = toSignal( + this.route.queryParamMap.pipe( + map((params) => (params.get('q') ?? '').trim()) + ), + { initialValue: '' } + ); + readonly isSearching = computed(() => this.query().length > 0); + readonly results = computed(() => { + this.languageTick(); + return this.settingsSearch.search(this.query()); + }); + + constructor() { + void this.settingsSearch.ensureEmbeddedMpvSupportLoaded(); + + effect(() => { + if (!this.isSearching()) { + this.settingsCtx.setMatchCounts(null); + return; + } + + const counts: Record = {}; + for (const { entry } of this.results()) { + counts[entry.section] = (counts[entry.section] ?? 0) + 1; + } + this.settingsCtx.setMatchCounts(counts); + }); + + inject(DestroyRef).onDestroy(() => { + this.settingsCtx.setMatchCounts(null); + this.clearHighlightTimer(); + }); + } + + select(entry: SettingsSearchEntry): void { + this.settingsSearch.reveal(entry); + } + + /** Starts handling reveal requests once the page can render them. */ + bindReveal({ activeSection, ready }: SettingsRevealBinding): void { + effect( + () => { + const request = this.settingsSearch.pendingReveal(); + if ( + !request || + !ready() || + this.isSearching() || + activeSection() !== request.section + ) { + return; + } + + afterNextRender(() => this.revealRow(request), { + injector: this.injector, + }); + }, + { injector: this.injector } + ); + } + + private revealRow(request: SettingsRevealRequest): void { + // A request superseded before this render is handled by its successor. + if (this.settingsSearch.pendingReveal()?.nonce !== request.nonce) { + return; + } + this.settingsSearch.completeReveal(request); + + const row = + this.findRow(request.id) ?? + (request.fallbackId ? this.findRow(request.fallbackId) : null); + if (!row) { + return; + } + + this.clearHighlight(); + row.scrollIntoView({ + block: 'center', + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + }); + // Moving focus to the row lets keyboard and screen-reader users + // continue from the setting they searched for; Tab reaches its + // control next. + if (!row.hasAttribute('tabindex')) { + row.setAttribute('tabindex', '-1'); + } + row.focus({ preventScroll: true }); + row.classList.add(SETTINGS_REVEALED_CLASS); + this.highlightTimeoutId = setTimeout( + () => this.clearHighlight(), + SETTINGS_REVEAL_HIGHLIGHT_MS + ); + } + + /** `id` comes from the static registry (`[a-z0-9-]`), never from input. */ + private findRow(id: string): HTMLElement | null { + return (this.host.nativeElement as HTMLElement).querySelector( + `[data-setting-id="${id}"]` + ); + } + + private clearHighlight(): void { + this.clearHighlightTimer(); + (this.host.nativeElement as HTMLElement) + .querySelectorAll(`.${SETTINGS_REVEALED_CLASS}`) + .forEach((element) => + element.classList.remove(SETTINGS_REVEALED_CLASS) + ); + } + + private clearHighlightTimer(): void { + if (this.highlightTimeoutId !== null) { + clearTimeout(this.highlightTimeoutId); + this.highlightTimeoutId = null; + } + } +} + +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html index c273acb47..fbb63aca5 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.html +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -10,7 +10,7 @@
-
+

{{ 'SETTINGS.TMDB_ENABLE' | translate }}

{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}

@@ -24,7 +24,7 @@
@if (form().value.tmdb?.enabled === true) { -
+

{{ 'SETTINGS.TMDB_API_KEY' | translate }}

{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}

@@ -96,7 +96,10 @@ @if (form().value.tmdb?.enabled === true) { @if (m3uVodDetailsControl; as m3uVodDetails) { -
+

{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }} @@ -118,7 +121,7 @@ } } -
+

{{ 'SETTINGS.TMDB_CACHE' | translate }}

diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index c3ee2e281..11649ffa6 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -16,113 +16,137 @@ - @switch (activeSection()) { - @case ('playback') { - - } - @case ('epg') { - - } - @case ('dashboard') { - - } - @case ('remote-control') { - - } - @case ('tmdb') { - - } - @case ('backup') { - - } - @case ('reset') { - - } - @case ('about') { - - } - @default { - + + @if (search.isSearching()) { + + } @else { + @switch (activeSection()) { + @case ('playback') { + + } + @case ('epg') { + + } + @case ('dashboard') { + + } + @case ('remote-control') { + + } + @case ('tmdb') { + + } + @case ('backup') { + + } + @case ('reset') { + + } + @case ('about') { + + } + @default { + + } } }

diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 7e6190bbb..e02e8e917 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -114,6 +114,64 @@ margin-bottom: 0; } +// A row opened from settings search or the command palette. It receives +// programmatic focus (tabindex -1). Keyboard users keep a focus ring after +// the temporary highlight fades; pointer users rely on the highlight alone, +// as with any mouse-driven focus. The wash fills the row's own padding box +// and two offset shadows widen it past the flush-left row edges without +// shifting layout or painting over the separators above and below. +.setting-item[data-setting-id] { + scroll-margin: 96px 0 calc(96px + var(--settings-action-bar-space)); + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid + color-mix(in srgb, var(--app-selection-color) 60%, transparent); + outline-offset: 6px; + border-radius: 4px; + } +} + +.setting-item--revealed { + --settings-reveal-wash: color-mix( + in srgb, + var(--app-selection-color) 12%, + transparent + ); + + background: var(--settings-reveal-wash); + box-shadow: + -12px 0 0 var(--settings-reveal-wash), + 12px 0 0 var(--settings-reveal-wash); + animation: settings-reveal-fade 2.4s ease-out forwards; +} + +@keyframes settings-reveal-fade { + 0%, + 60% { + background: var(--settings-reveal-wash); + box-shadow: + -12px 0 0 var(--settings-reveal-wash), + 12px 0 0 var(--settings-reveal-wash); + } + + 100% { + background: transparent; + box-shadow: + -12px 0 0 transparent, + 12px 0 0 transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + .setting-item--revealed { + animation: none; + } +} + .setting-item__meta { h4 { margin: 0; diff --git a/apps/web/src/app/settings/settings.component.search.spec.ts b/apps/web/src/app/settings/settings.component.search.spec.ts new file mode 100644 index 000000000..197ffd3e4 --- /dev/null +++ b/apps/web/src/app/settings/settings.component.search.spec.ts @@ -0,0 +1,181 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { + SETTINGS_SEARCH_ENTRIES, + SettingsSearchEntry, + SettingsSearchService, +} from '@iptvnator/workspace/shell/util/settings-search'; +import { SettingsComponent } from './settings.component'; +import { SETTINGS_REVEALED_CLASS } from './settings-search.facade'; +import { + configureSettingsComponentTestBed, + createElectronStub, + createEpgBridgeStub, + MockRouter, + setSettingsSearchQuery, + setSettingsSection, + stubSettingsSideEffects, +} from './test-stubs/settings-test-harness.stub'; + +function entry(id: string): SettingsSearchEntry { + const found = SETTINGS_SEARCH_ENTRIES.find((item) => item.id === id); + if (!found) { + throw new Error(`unknown settings entry ${id}`); + } + return found; +} + +/** + * Settings search on the page: the shell writes the header term to `q`, + * the page swaps the section for ranked results, and a chosen result (or a + * command palette pick) scrolls to and highlights the row. The labels are + * translation keys here (no translations are loaded), which the index + * searches like any other text. + */ +describe('SettingsComponent search', () => { + let fixture: ComponentFixture; + let component: SettingsComponent; + let settingsSearch: SettingsSearchService; + let settingsCtx: SettingsContextService; + let scrollIntoView: jest.Mock; + const originalElectron = window.electron; + const originalScrollIntoView = Element.prototype.scrollIntoView; + + const element = (): HTMLElement => fixture.nativeElement as HTMLElement; + const query = (selector: string) => element().querySelector(selector); + + beforeEach(waitForAsync(() => { + configureSettingsComponentTestBed(createEpgBridgeStub()); + })); + + beforeEach(async () => { + window.electron = createElectronStub(); + scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + + fixture = TestBed.createComponent(SettingsComponent); + component = fixture.componentInstance; + settingsSearch = TestBed.inject(SettingsSearchService); + settingsCtx = TestBed.inject(SettingsContextService); + stubSettingsSideEffects(component); + const loadSettings = jest.spyOn(component.form, 'loadSettings'); + fixture.detectChanges(); + await loadSettings.mock.results[0].value; + await fixture.whenStable(); + fixture.detectChanges(); + }); + + afterEach(() => { + jest.useRealTimers(); + window.electron = originalElectron; + Element.prototype.scrollIntoView = originalScrollIntoView; + }); + + it('replaces the section page with ranked results while searching', () => { + expect(query('app-settings-general-section')).not.toBeNull(); + + setSettingsSearchQuery('theme'); + fixture.detectChanges(); + + expect(query('app-settings-general-section')).toBeNull(); + expect( + query('[data-test-id="settings-search-result-theme"]') + ).not.toBeNull(); + expect( + query('[data-test-id="settings-search-summary"]')?.getAttribute( + 'aria-live' + ) + ).toBe('polite'); + + setSettingsSearchQuery(''); + fixture.detectChanges(); + + expect(query('[data-test-id="settings-search-results"]')).toBeNull(); + expect(query('app-settings-general-section')).not.toBeNull(); + }); + + it('shows an empty state when nothing matches', () => { + setSettingsSearchQuery('zzzz-no-such-setting'); + fixture.detectChanges(); + + expect(query('[data-test-id="settings-search-empty"]')).not.toBeNull(); + expect(query('.settings-search-result')).toBeNull(); + }); + + it('publishes match counts per section for the navigation', () => { + setSettingsSearchQuery('dark'); + fixture.detectChanges(); + + expect(settingsCtx.matchCounts()).toEqual( + expect.objectContaining({ general: 1 }) + ); + + setSettingsSearchQuery(''); + fixture.detectChanges(); + expect(settingsCtx.matchCounts()).toBeNull(); + }); + + it('reveals the chosen result through the settings search service', () => { + const reveal = jest.spyOn(settingsSearch, 'reveal'); + setSettingsSearchQuery('theme'); + fixture.detectChanges(); + + ( + query( + '[data-test-id="settings-search-result-theme"]' + ) as HTMLButtonElement + ).click(); + + expect(reveal).toHaveBeenCalledWith(entry('theme')); + expect( + (TestBed.inject(Router) as unknown as MockRouter).navigate + ).toHaveBeenCalledWith(['/workspace/settings', 'general']); + }); + + it('scrolls to, focuses and briefly highlights a revealed row', () => { + jest.useFakeTimers(); + + settingsSearch.reveal(entry('show-captions')); + fixture.detectChanges(); + + const row = query('[data-setting-id="show-captions"]') as HTMLElement; + expect(scrollIntoView).toHaveBeenCalled(); + expect(scrollIntoView.mock.contexts[0]).toBe(row); + expect(row.classList).toContain(SETTINGS_REVEALED_CLASS); + expect(document.activeElement).toBe(row); + expect(settingsSearch.pendingReveal()).toBeNull(); + + jest.runOnlyPendingTimers(); + expect(row.classList).not.toContain(SETTINGS_REVEALED_CLASS); + }); + + it('waits for the section page before revealing a row on it', () => { + settingsSearch.reveal(entry('epg-offset')); + fixture.detectChanges(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(settingsSearch.pendingReveal()?.id).toBe('epg-offset'); + + setSettingsSection('epg'); + fixture.detectChanges(); + + expect(query('[data-setting-id="epg-offset"]')?.classList).toContain( + SETTINGS_REVEALED_CLASS + ); + }); + + it('falls back to the controlling row when the searched row is hidden', () => { + // The API key row only renders while TMDB is enabled. + setSettingsSection('tmdb'); + fixture.detectChanges(); + expect(query('[data-setting-id="tmdb-api-key"]')).toBeNull(); + + settingsSearch.reveal(entry('tmdb-api-key')); + fixture.detectChanges(); + + expect(query('[data-setting-id="tmdb-enable"]')?.classList).toContain( + SETTINGS_REVEALED_CLASS + ); + }); +}); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index a48c8381d..e66083e33 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -6,6 +6,7 @@ import { inject, OnDestroy, OnInit, + signal, ViewEncapsulation, ChangeDetectionStrategy, } from '@angular/core'; @@ -49,6 +50,8 @@ import { SettingsPlaybackSectionComponent } from './settings-playback-section.co import { SettingsRemoteControlFacade } from './settings-remote-control.facade'; import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; import { SettingsResetSectionComponent } from './settings-reset-section.component'; +import { SettingsSearchFacade } from './settings-search.facade'; +import { SettingsSearchResultsComponent } from './settings-search-results.component'; import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component'; import { SettingsUnsavedChangesChoice, @@ -93,6 +96,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsPlaybackSectionComponent, SettingsRemoteControlSectionComponent, SettingsResetSectionComponent, + SettingsSearchResultsComponent, SettingsTmdbSectionComponent, ], // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. @@ -105,6 +109,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsFormFacade, SettingsPlaylistResetFacade, SettingsRemoteControlFacade, + SettingsSearchFacade, SettingsSnackbarService, SettingsUnloadGuardService, ], @@ -119,6 +124,7 @@ export class SettingsComponent readonly form = inject(SettingsFormFacade); readonly playlistReset = inject(SettingsPlaylistResetFacade); readonly remoteControl = inject(SettingsRemoteControlFacade); + readonly search = inject(SettingsSearchFacade); private readonly settingsCtx = inject(SettingsContextService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -155,6 +161,9 @@ export class SettingsComponent /** Settings form object */ readonly settingsForm = this.form.form; + /** Set once the form is hydrated, so form-dependent rows can render. */ + private readonly formReady = signal(false); + /** Player options */ readonly players = computed(() => buildSettingsPlayerOptions({ @@ -219,10 +228,16 @@ export class SettingsComponent // on purpose โ€” this is navigation, not an animated transition. effect(() => { this.activeSection(); + this.search.isSearching(); this.hostElement.nativeElement .closest('main.workspace-content') ?.scrollTo({ top: 0 }); }); + + this.search.bindReveal({ + activeSection: this.activeSection, + ready: this.formReady.asReadonly(), + }); } /** @@ -241,6 +256,7 @@ export class SettingsComponent await this.form.loadSettings(); this.form.hydrateFromStore(); this.form.bindDashboardControlsEnabledState(); + this.formReady.set(true); void this.embeddedMpv.load(); this.appUpdate.checkAppVersion(); this.appUpdate.init(); diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 4a2b516ef..7c7925187 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -146,19 +146,28 @@ export class MockRouter { } /** - * Stands in for the `:section` route param the settings page renders from. - * Specs switch section pages with `setSettingsSection` below. + * Stands in for the `:section` route param the settings page renders from + * and the shell-owned `q` search param. Specs switch section pages with + * `setSettingsSection` and search with `setSettingsSearchQuery` below. */ export class MockActivatedRoute { private readonly params = new BehaviorSubject( convertToParamMap({ section: 'general' }) ); + private readonly queryParams = new BehaviorSubject( + convertToParamMap({}) + ); readonly paramMap = this.params.asObservable(); + readonly queryParamMap = this.queryParams.asObservable(); setSection(section: string): void { this.params.next(convertToParamMap({ section })); } + + setQuery(query: string): void { + this.queryParams.next(convertToParamMap(query ? { q: query } : {})); + } } /** Routes the rendered settings page to the given section page. */ @@ -168,6 +177,13 @@ export function setSettingsSection(section: string): void { ).setSection(section); } +/** Sets the header search term (`q`) the settings page searches for. */ +export function setSettingsSearchQuery(query: string): void { + ( + TestBed.inject(ActivatedRoute) as unknown as MockActivatedRoute + ).setQuery(query); +} + export class MockSettingsStore { private _settings = signal(DEFAULT_SETTINGS); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 00e92dc71..cf337f290 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "ุจุนุฏ ุชูƒุฑุงุฑ ูุดู„ ุงู„ุงุชุตุงู„ุŒ ุชููˆู‚ู ุทู„ุจุงุช Xtream ูˆStalker ุงู„ุฌุฏูŠุฏุฉ ู„ู…ุฏุฉ 30 ุซุงู†ูŠุฉ. ุฌุฑู‘ุจ ุชุนุทูŠู„ ู‡ุฐุง ุงู„ุฎูŠุงุฑ ุฅุฐุง ูƒุงู†ุช ุงู„ุจูˆุงุจุฉ ุชุตุจุญ ุบูŠุฑ ู…ุชุงุญุฉ ุจุดูƒู„ ู…ุชู‚ุทุน. ูŠูุทุจู‘ู‚ ุจุนุฏ ุงู„ุญูุธ ุฏูˆู† ุฅุนุงุฏุฉ ุงู„ุชุดุบูŠู„.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ุณูŠูู†ูู‘ุฐ ูƒู„ ุทู„ุจ ุจุดูƒู„ ู…ุณุชู‚ู„. ู‚ุฏ ูŠุณุชุบุฑู‚ ุธู‡ูˆุฑ ุงู„ุฎุทุฃ ูˆู‚ุชู‹ุง ุฃุทูˆู„ ุนู†ุฏ ุนุฏู… ุชูˆูุฑ ุงู„ุฎุงุฏู….", "TITLE": "ุงู„ุฅุนุฏุงุฏุงุช", + "SEARCH_RESULTS_TITLE": "ู†ุชุงุฆุฌ ุงู„ุจุญุซ", + "SEARCH_RESULTS_COUNT": "ุงู„ุฅุนุฏุงุฏุงุช ุงู„ู…ุทุงุจู‚ุฉ ู„ู€ ยซ{{query}}ยป: {{count}}", + "SEARCH_EMPTY_HINT": "ู„ุง ุชูˆุฌุฏ ุฅุนุฏุงุฏุงุช ู…ุทุงุจู‚ุฉ. ุฌุฑู‘ุจ ูƒู„ู…ุฉ ุฃุฎุฑู‰ุŒ ู…ุซู„ ุงู„ู…ุดุบู„ ุฃูˆ ุงู„ุณู…ุฉ ุฃูˆ EPG.", "GENERAL": "ุนุงู…", "GENERAL_SUBTITLE": "ุชุบูŠูŠุฑ ุชูƒูˆูŠู† ุงู„ุชุทุจูŠู‚", "PLAYBACK": "ุงู„ุชุดุบูŠู„", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ุฅุฌุฑุงุกุงุช ุงู„ุณูŠุงู‚", "SEARCH_LABEL": "ุจุญุซ", "SEARCH_SOURCES_PLACEHOLDER": "ุงู„ุจุญุซ ููŠ ุงู„ู…ุตุงุฏุฑ (ุฌู…ูŠุน ู‚ูˆุงุฆู… ุงู„ุชุดุบูŠู„)...", + "SEARCH_SETTINGS_PLACEHOLDER": "ุงู„ุจุญุซ ููŠ ุงู„ุฅุนุฏุงุฏุงุช...", "SEARCH_GLOBAL_PLACEHOLDER": "ุงู„ุจุญุซ ููŠ ูƒู„ ู‚ูˆุงุฆู… ุงู„ุชุดุบูŠู„...", "SEARCH_PLAYLIST_PLACEHOLDER": "ุงู„ุจุญุซ ููŠ ู‚ุงุฆู…ุฉ ุงู„ุชุดุบูŠู„ ู‡ุฐู‡...", "SEARCH_SECTION_PLACEHOLDER": "ุงู„ุจุญุซ ููŠ ู‡ุฐุง ุงู„ู‚ุณู…...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ู„ูˆุญุฉ ุงู„ุฃูˆุงู…ุฑ", "SUBTITLE": "ุฅุฌุฑุงุกุงุช ุนุงู…ุฉ ูˆุณูŠุงู‚ูŠุฉ", - "SEARCH_PLACEHOLDER": "ุงูƒุชุจ ู„ุชุตููŠุฉ ุงู„ุฃูˆุงู…ุฑ", + "SEARCH_PLACEHOLDER": "ุงู„ุจุญุซ ููŠ ุงู„ุฃูˆุงู…ุฑ ูˆุงู„ุฅุนุฏุงุฏุงุช", "SEARCH_LABEL": "ุชุตููŠุฉ ุงู„ุฃูˆุงู…ุฑ", "EMPTY": "ู„ู… ูŠุชู… ุงู„ุนุซูˆุฑ ุนู„ู‰ ุฃูˆุงู…ุฑ", "GROUP_GLOBAL": "ุงู„ุฅุฌุฑุงุกุงุช ุงู„ุนุงู…ุฉ", "GROUP_PLAYLIST": "ู‚ุงุฆู…ุฉ ุงู„ุชุดุบูŠู„ ู‡ุฐู‡", "GROUP_VIEW": "ุงู„ุนุฑุถ ุงู„ุญุงู„ูŠ", "GROUP_SECTION": "ู‡ุฐุง ุงู„ู‚ุณู…", - "GROUP_RECENT": "ุงู„ู…ุณุชุฎุฏู…ุฉ ู…ุคุฎุฑู‹ุง" + "GROUP_RECENT": "ุงู„ู…ุณุชุฎุฏู…ุฉ ู…ุคุฎุฑู‹ุง", + "GROUP_SETTINGS": "ุงู„ุฅุนุฏุงุฏุงุช" }, "SHORTCUTS": { "TITLE": "ุงุฎุชุตุงุฑุงุช ู„ูˆุญุฉ ุงู„ู…ูุงุชูŠุญ", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 1b4be9931..f35b57c18 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "ู…ู† ุจุนุฏ ู…ุง ูŠูุดู„ ุงู„ุงุชุตุงู„ ุนุฏุฉ ู…ุฑุงุชุŒ ูƒูŠูˆู‚ู ุทู„ุจุงุช Xtream ูˆStalker ุงู„ุฌุฏูŠุฏุฉ ู„ู…ุฏุฉ 30 ุซุงู†ูŠุฉ. ุฌุฑู‘ุจ ุชุทููŠ ู‡ุงุฏ ุงู„ุฎูŠุงุฑ ุฅู„ุง ูƒุงู†ุช ุงู„ุจูˆุงุจุฉ ูƒุชู‚ุทุน ู…ุฑุฉ ู…ุฑุฉ. ูƒูŠุชุทุจู‚ ู…ู† ุจุนุฏ ุงู„ุญูุธ ุจู„ุง ุฅุนุงุฏุฉ ุงู„ุชุดุบูŠู„.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ูƒู„ ุทู„ุจ ุบุงุฏูŠ ูŠุชู†ูุฐ ุจูˆุญุฏูˆ. ุฅู„ุง ูƒุงู† ุงู„ุณูŠุฑูุฑ ู…ุง ู…ุชุงุญุดุŒ ุฑุณุงู„ุฉ ุงู„ุฎุทุฃ ุชู‚ุฏุฑ ุชุชุฃุฎุฑ.", "TITLE": "ุงู„ุฅุนุฏุงุฏุงุช", + "SEARCH_RESULTS_TITLE": "ู†ุชุงุฆุฌ ุงู„ุจุญุซ", + "SEARCH_RESULTS_COUNT": "ุงู„ุฅุนุฏุงุฏุงุช ุงู„ู„ูŠ ูƒุชุทุงุจู‚ ยซ{{query}}ยป: {{count}}", + "SEARCH_EMPTY_HINT": "ู…ุง ูƒุงูŠู† ุญุชู‰ ุฅุนุฏุงุฏ ู…ุทุงุจู‚. ุฌุฑุจ ูƒู„ู…ุฉ ุฃุฎุฑู‰ุŒ ุจุญุงู„ ุงู„ู…ุดุบู„ ูˆู„ุง ุงู„ู…ุธู‡ุฑ ูˆู„ุง EPG.", "GENERAL": "ุนุงู…", "GENERAL_SUBTITLE": "ุจุฏู„ ุชูƒูˆูŠู† ุงู„ุชุทุจูŠู‚", "PLAYBACK": "ุงู„ุชุดุบูŠู„", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ุฅุฌุฑุงุกุงุช ุงู„ู‚ุณู…", "SEARCH_LABEL": "ุจุญุซ", "SEARCH_SOURCES_PLACEHOLDER": "ู‚ู„ุจ ุนู„ู‰ ู…ุตุงุฏุฑ (ุฌู…ูŠุน ู‚ูˆุงุฆู… ุงู„ุชุดุบูŠู„)...", + "SEARCH_SETTINGS_PLACEHOLDER": "ู‚ู„ุจ ูุงู„ุฅุนุฏุงุฏุงุช...", "SEARCH_GLOBAL_PLACEHOLDER": "ู‚ู„ุจ ููƒู„ ู„ูˆุงุฆุญ ุงู„ุชุดุบูŠู„...", "SEARCH_PLAYLIST_PLACEHOLDER": "ู‚ู„ุจ ููŠ ู‡ุงุฏ ู‚ุงุฆู…ุฉ ุงู„ุชุดุบูŠู„...", "SEARCH_SECTION_PLACEHOLDER": "ู‚ู„ุจ ูู‡ุงุฏ ุงู„ู‚ุณู…...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ู„ูˆุญุฉ ุงู„ุฃูˆุงู…ุฑ", "SUBTITLE": "ุฅุฌุฑุงุกุงุช ุนุงู…ุฉ ูˆุณูŠุงู‚ูŠุฉ", - "SEARCH_PLACEHOLDER": "ูƒุชุจ ุจุงุด ุชูู„ุชุฑ ุงู„ุฃูˆุงู…ุฑ", + "SEARCH_PLACEHOLDER": "ู‚ู„ุจ ุนู„ู‰ ุงู„ุฃูˆุงู…ุฑ ูˆุงู„ุฅุนุฏุงุฏุงุช", "SEARCH_LABEL": "ูู„ุชุฑ ุงู„ุฃูˆุงู…ุฑ", "EMPTY": "ู…ุง ุชู„ู‚ุงุชุด ุญุชู‰ ุฃู…ุฑ", "GROUP_GLOBAL": "ุฅุฌุฑุงุกุงุช ุนุงู…ุฉ", "GROUP_PLAYLIST": "ู‡ุงุฏ ู‚ุงุฆู…ุฉ ุงู„ุชุดุบูŠู„", "GROUP_VIEW": "ุงู„ุนุฑุถ ุงู„ุญุงู„ูŠ", "GROUP_SECTION": "ู‡ุงุฏ ุงู„ู‚ุณู…", - "GROUP_RECENT": "ู…ุณุชุนู…ู„ุฉ ู…ุคุฎุฑุงู‹" + "GROUP_RECENT": "ู…ุณุชุนู…ู„ุฉ ู…ุคุฎุฑุงู‹", + "GROUP_SETTINGS": "ุงู„ุฅุนุฏุงุฏุงุช" }, "SHORTCUTS": { "TITLE": "ุงุฎุชุตุงุฑุงุช ุงู„ูƒู„ุงููŠูŠ", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 36d299200..a45938c0c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "ะŸะฐัะปั ะฟะฐัžั‚ะพั€ะฝั‹ั… ะฟะฐะผั‹ะปะฐะบ ะฟะฐะดะบะปัŽั‡ัะฝะฝั ะฟั€ั‹ะฟั‹ะฝัั†ัŒ ะฝะพะฒั‹ั ะทะฐะฟั‹ั‚ั‹ Xtream ั– Stalker ะฝะฐ 30 ัะตะบัƒะฝะด. ะŸะฐัะฟั€ะฐะฑัƒะนั†ะต ะฒั‹ะบะปัŽั‡ั‹ั†ัŒ, ะบะฐะปั– ะฟะฐั€ั‚ะฐะป ะฟะตั€ั‹ัะดั‹ั‡ะฝะฐ ัั‚ะฐะฝะพะฒั–ั†ั†ะฐ ะฝะตะดะฐัั‚ัƒะฟะฝั‹ะผ. ะฃะถั‹ะฒะฐะตั†ั†ะฐ ะฟะฐัะปั ะทะฐั…ะฐะฒะฐะฝะฝั ะฑะตะท ะฟะตั€ะฐะทะฐะฟัƒัะบัƒ.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ะšะพะถะฝั‹ ะทะฐะฟั‹ั‚ ะฑัƒะดะทะต ะฒั‹ะบะพะฝะฒะฐั†ั†ะฐ ะฝะตะทะฐะปะตะถะฝะฐ. ะšะฐะปั– ัะตั€ะฒะตั€ ะฝะตะดะฐัั‚ัƒะฟะฝั‹, ั‡ะฐะบะฐะฝะฝะต ะฟะฐะผั‹ะปะบั– ะผะพะถะฐ ะฟะฐะฒัะปั–ั‡ั‹ั†ั†ะฐ.", "TITLE": "ะะฐะปะฐะดั‹", + "SEARCH_RESULTS_TITLE": "ะ’ั‹ะฝั–ะบั– ะฟะพัˆัƒะบัƒ", + "SEARCH_RESULTS_COUNT": "ะะฐะปะฐะดั‹ ะฟะฐ ะทะฐะฟั‹ั†ะต ยซ{{query}}ยป: {{count}}", + "SEARCH_EMPTY_HINT": "ะั–ั‡ะพะณะฐ ะฝะต ะทะฝะพะนะดะทะตะฝะฐ. ะŸะฐัะฟั€ะฐะฑัƒะนั†ะต ั–ะฝัˆะฐะต ัะปะพะฒะฐ, ะฝะฐะฟั€ั‹ะบะปะฐะด ยซะฟั€ะฐะนะณั€ะฐะฒะฐะปัŒะฝั–ะบยป, ยซั‚ัะผะฐยป ั†ั– ยซEPGยป.", "GENERAL": "ะะณัƒะปัŒะฝั‹ั", "GENERAL_SUBTITLE": "ะ—ะผัะฝั–ั†ะต ะบะฐะฝั„ั–ะณัƒั€ะฐั†ั‹ัŽ ะฟั€ะฐะณั€ะฐะผั‹", "PLAYBACK": "ะŸั€ะฐะนะณั€ะฐะฒะฐะฝะฝะต", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ะšะฐะฝั‚ัะบัั‚ะฝั‹ั ะดะทะตัะฝะฝั–", "SEARCH_LABEL": "ะŸะพัˆัƒะบ", "SEARCH_SOURCES_PLACEHOLDER": "ะŸะพัˆัƒะบ ะบั€ั‹ะฝั–ั† (ัƒัะต ะฟะปัะนะปั–ัั‚ั‹)...", + "SEARCH_SETTINGS_PLACEHOLDER": "ะจัƒะบะฐั†ัŒ ัƒ ะฝะฐะปะฐะดะฐั…...", "SEARCH_GLOBAL_PLACEHOLDER": "ะจัƒะบะฐั†ัŒ ะฒะฐ ัžัั–ั… ะฟะปัะนะปั–ัั‚ะฐั…...", "SEARCH_PLAYLIST_PLACEHOLDER": "ะŸะพัˆัƒะบ ัƒ ะณัั‚ั‹ะผ ะฟะปัะนะปั–ัั†ะต...", "SEARCH_SECTION_PLACEHOLDER": "ะจัƒะบะฐั†ัŒ ัƒ ะณัั‚ั‹ะผ ั€ะฐะทะดะทะตะปะต...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ะŸะฐะปั–ั‚ั€ะฐ ะบะฐะผะฐะฝะด", "SUBTITLE": "ะ“ะปะฐะฑะฐะปัŒะฝั‹ั ั– ะบะฐะฝั‚ัะบัั‚ะฝั‹ั ะดะทะตัะฝะฝั–", - "SEARCH_PLACEHOLDER": "ะฃะฒัะดะทั–ั†ะต ะดะปั ั„ั–ะปัŒั‚ั€ะฐั†ั‹ั– ะบะฐะผะฐะฝะด", + "SEARCH_PLACEHOLDER": "ะจัƒะบะฐั†ัŒ ะบะฐะผะฐะฝะดั‹ ั– ะฝะฐะปะฐะดั‹", "SEARCH_LABEL": "ะคั–ะปัŒั‚ั€ ะบะฐะผะฐะฝะด", "EMPTY": "ะšะฐะผะฐะฝะด ะฝะต ะทะฝะพะนะดะทะตะฝะฐ", "GROUP_GLOBAL": "ะ“ะปะฐะฑะฐะปัŒะฝั‹ั ะดะทะตัะฝะฝั–", "GROUP_PLAYLIST": "ะ“ัั‚ั‹ ะฟะปัะนะปั–ัั‚", "GROUP_VIEW": "ะ‘ัะณัƒั‡ั‹ ะฒั‹ะณะปัะด", "GROUP_SECTION": "ะ“ัั‚ั‹ ั€ะฐะทะดะทะตะป", - "GROUP_RECENT": "ะัะดะฐัžะฝะฐ ะฒั‹ะบะฐั€ั‹ัั‚ะฐะฝั‹ั" + "GROUP_RECENT": "ะัะดะฐัžะฝะฐ ะฒั‹ะบะฐั€ั‹ัั‚ะฐะฝั‹ั", + "GROUP_SETTINGS": "ะะฐะปะฐะดั‹" }, "SHORTCUTS": { "TITLE": "ะกะฟะฐะปัƒั‡ัะฝะฝั– ะบะปะฐะฒั–ัˆ", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f60cf8531..42106ef24 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Nach wiederholten Verbindungsfehlern neue Xtream- und Stalker-Anfragen 30 Sekunden pausieren. Bei zeitweise nicht erreichbaren Portalen versuchsweise deaktivieren. Gilt nach dem Speichern ohne Neustart.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Jede Anfrage wird unabhรคngig ausgefรผhrt. Bei nicht erreichbaren Servern kann die Fehlermeldung lรคnger dauern.", "TITLE": "Einstellungen", + "SEARCH_RESULTS_TITLE": "Suchergebnisse", + "SEARCH_RESULTS_COUNT": "Einstellungen zu โ€ž{{query}}โ€œ: {{count}}", + "SEARCH_EMPTY_HINT": "Keine passenden Einstellungen. Ein anderes Wort versuchen, etwa Player, Design oder EPG.", "GENERAL": "Allgemein", "GENERAL_SUBTITLE": "Allgemeine App-Einstellungen รคndern", "PLAYBACK": "Wiedergabe", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Abschnittsaktionen", "SEARCH_LABEL": "Suche", "SEARCH_SOURCES_PLACEHOLDER": "Quellen durchsuchen (alle Playlists)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Einstellungen durchsuchen...", "SEARCH_GLOBAL_PLACEHOLDER": "Alle Wiedergabelisten durchsuchen...", "SEARCH_PLAYLIST_PLACEHOLDER": "In dieser Playlist suchen...", "SEARCH_SECTION_PLACEHOLDER": "In diesem Abschnitt suchen...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Befehlspalette", "SUBTITLE": "Globale und kontextbezogene Aktionen", - "SEARCH_PLACEHOLDER": "Zum Filtern der Befehle tippen", + "SEARCH_PLACEHOLDER": "Befehle und Einstellungen durchsuchen", "SEARCH_LABEL": "Befehle filtern", "EMPTY": "Keine Befehle gefunden", "GROUP_GLOBAL": "Globale Aktionen", "GROUP_PLAYLIST": "Diese Playlist", "GROUP_VIEW": "Aktuelle Ansicht", "GROUP_SECTION": "Dieser Abschnitt", - "GROUP_RECENT": "Kรผrzlich verwendet" + "GROUP_RECENT": "Kรผrzlich verwendet", + "GROUP_SETTINGS": "Einstellungen" }, "SHORTCUTS": { "TITLE": "Tastenkรผrzel", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dbfb4cc58..184b7aef7 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "ฮœฮตฯ„ฮฌ ฮฑฯ€ฯŒ ฮตฯ€ฮฑฮฝฮตฮนฮปฮทฮผฮผฮญฮฝฮฑ ฯƒฯ†ฮฌฮปฮผฮฑฯ„ฮฑ ฯƒฯฮฝฮดฮตฯƒฮทฯ‚, ฯ€ฮฑฯฯƒฮท ฮฝฮญฯ‰ฮฝ ฮฑฮนฯ„ฮทฮผฮฌฯ„ฯ‰ฮฝ Xtream ฮบฮฑฮน Stalker ฮณฮนฮฑ 30 ฮดฮตฯ…ฯ„ฮตฯฯŒฮปฮตฯ€ฯ„ฮฑ. ฮ”ฮฟฮบฮนฮผฮฌฯƒฯ„ฮต ฮฝฮฑ ฯ„ฮฟ ฮฑฯ€ฮตฮฝฮตฯฮณฮฟฯ€ฮฟฮนฮฎฯƒฮตฯ„ฮต ฮฑฮฝ ฮผฮนฮฑ ฯ€ฯฮปฮท ฮตฮฏฮฝฮฑฮน ฮบฮฑฯ„ฮฌ ฮดฮนฮฑฯƒฯ„ฮฎฮผฮฑฯ„ฮฑ ฮผฮท ฮดฮนฮฑฮธฮญฯƒฮนฮผฮท. ฮ™ฯƒฯ‡ฯฮตฮน ฮผฮตฯ„ฮฌ ฯ„ฮทฮฝ ฮฑฯ€ฮฟฮธฮฎฮบฮตฯ…ฯƒฮท, ฯ‡ฯ‰ฯฮฏฯ‚ ฮตฯ€ฮฑฮฝฮตฮบฮบฮฏฮฝฮทฯƒฮท.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ฮšฮฌฮธฮต ฮฑฮฏฯ„ฮทฮผฮฑ ฮตฮบฯ„ฮตฮปฮตฮฏฯ„ฮฑฮน ฮฑฮฝฮตฮพฮฌฯฯ„ฮทฯ„ฮฑ. ฮŸฮน ฮผฮท ฮดฮนฮฑฮธฮญฯƒฮนฮผฮฟฮน ฮดฮนฮฑฮบฮฟฮผฮนฯƒฯ„ฮญฯ‚ ฮผฯ€ฮฟฯฮตฮฏ ฮฝฮฑ ฮฑฯฮณฮฟฯฮฝ ฯ€ฮตฯฮนฯƒฯƒฯŒฯ„ฮตฯฮฟ ฮฝฮฑ ฮฑฮฝฮฑฯ†ฮญฯฮฟฯ…ฮฝ ฯƒฯ†ฮฌฮปฮผฮฑ.", "TITLE": "ฮกฯ…ฮธฮผฮฏฯƒฮตฮนฯ‚", + "SEARCH_RESULTS_TITLE": "ฮ‘ฯ€ฮฟฯ„ฮตฮปฮญฯƒฮผฮฑฯ„ฮฑ ฮฑฮฝฮฑฮถฮฎฯ„ฮทฯƒฮทฯ‚", + "SEARCH_RESULTS_COUNT": "ฮกฯ…ฮธฮผฮฏฯƒฮตฮนฯ‚ ฮณฮนฮฑ ยซ{{query}}ยป: {{count}}", + "SEARCH_EMPTY_HINT": "ฮ”ฮตฮฝ ฮฒฯฮญฮธฮทฮบฮฑฮฝ ฯฯ…ฮธฮผฮฏฯƒฮตฮนฯ‚. ฮ”ฮฟฮบฮนฮผฮฌฯƒฯ„ฮต ฮฌฮปฮปฮท ฮปฮญฮพฮท, ฯ€.ฯ‡. ฮฑฮฝฮฑฯ€ฮฑฯฮฑฮณฯ‰ฮณฮฎ, ฮธฮญฮผฮฑ ฮฎ EPG.", "GENERAL": "ฮ“ฮตฮฝฮนฮบฮฌ", "GENERAL_SUBTITLE": "ฮ‘ฮปฮปฮฑฮณฮฎ ฯ„ฮทฯ‚ ฮดฮนฮฑฮผฯŒฯฯ†ฯ‰ฯƒฮทฯ‚ ฯ„ฮทฯ‚ ฮตฯ†ฮฑฯฮผฮฟฮณฮฎฯ‚", "PLAYBACK": "ฮ‘ฮฝฮฑฯ€ฮฑฯฮฑฮณฯ‰ฮณฮฎ", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ฮ•ฮฝฮญฯฮณฮตฮนฮตฯ‚ ฮตฮฝฯŒฯ„ฮทฯ„ฮฑฯ‚", "SEARCH_LABEL": "ฮ‘ฮฝฮฑฮถฮฎฯ„ฮทฯƒฮท", "SEARCH_SOURCES_PLACEHOLDER": "ฮ‘ฮฝฮฑฮถฮฎฯ„ฮทฯƒฮท ฯ€ฮทฮณฯŽฮฝ (ฯŒฮปฮตฯ‚ ฮฟฮน ฮปฮฏฯƒฯ„ฮตฯ‚ ฮฑฮฝฮฑฯ€ฮฑฯฮฑฮณฯ‰ฮณฮฎฯ‚)...", + "SEARCH_SETTINGS_PLACEHOLDER": "ฮ‘ฮฝฮฑฮถฮฎฯ„ฮทฯƒฮท ฯฯ…ฮธฮผฮฏฯƒฮตฯ‰ฮฝ...", "SEARCH_GLOBAL_PLACEHOLDER": "ฮ‘ฮฝฮฑฮถฮฎฯ„ฮทฯƒฮท ฯƒฮต ฯŒฮปฮตฯ‚ ฯ„ฮนฯ‚ ฮปฮฏฯƒฯ„ฮตฯ‚...", "SEARCH_PLAYLIST_PLACEHOLDER": "ฮ‘ฮฝฮฑฮถฮฎฯ„ฮทฯƒฮท ฯƒฮต ฮฑฯ…ฯ„ฮฎ ฯ„ฮท ฮปฮฏฯƒฯ„ฮฑ ฮฑฮฝฮฑฯ€ฮฑฯฮฑฮณฯ‰ฮณฮฎฯ‚...", "SEARCH_SECTION_PLACEHOLDER": "ฮ‘ฮฝฮฑฮถฮฎฯ„ฮทฯƒฮท ฯƒฮต ฮฑฯ…ฯ„ฮฎ ฯ„ฮทฮฝ ฮตฮฝฯŒฯ„ฮทฯ„ฮฑ...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ฮ ฮฑฮปฮญฯ„ฮฑ ฮตฮฝฯ„ฮฟฮปฯŽฮฝ", "SUBTITLE": "ฮšฮฑฮธฮฟฮปฮนฮบฮญฯ‚ ฮบฮฑฮน ฯƒฯ…ฮผฯ†ฯฮฑฮถฯŒฮผฮตฮฝฮตฯ‚ ฮตฮฝฮญฯฮณฮตฮนฮตฯ‚", - "SEARCH_PLACEHOLDER": "ฮ ฮปฮทฮบฯ„ฯฮฟฮปฮฟฮณฮฎฯƒฯ„ฮต ฮณฮนฮฑ ฯ†ฮนฮปฯ„ฯฮฌฯฮนฯƒฮผฮฑ ฮตฮฝฯ„ฮฟฮปฯŽฮฝ", + "SEARCH_PLACEHOLDER": "ฮ‘ฮฝฮฑฮถฮฎฯ„ฮทฯƒฮท ฮตฮฝฯ„ฮฟฮปฯŽฮฝ ฮบฮฑฮน ฯฯ…ฮธฮผฮฏฯƒฮตฯ‰ฮฝ", "SEARCH_LABEL": "ฮฆฮนฮปฯ„ฯฮฌฯฮนฯƒฮผฮฑ ฮตฮฝฯ„ฮฟฮปฯŽฮฝ", "EMPTY": "ฮ”ฮตฮฝ ฮฒฯฮญฮธฮทฮบฮฑฮฝ ฮตฮฝฯ„ฮฟฮปฮญฯ‚", "GROUP_GLOBAL": "ฮšฮฑฮธฮฟฮปฮนฮบฮญฯ‚ ฮตฮฝฮญฯฮณฮตฮนฮตฯ‚", "GROUP_PLAYLIST": "ฮ‘ฯ…ฯ„ฮฎ ฮท ฮปฮฏฯƒฯ„ฮฑ ฮฑฮฝฮฑฯ€ฮฑฯฮฑฮณฯ‰ฮณฮฎฯ‚", "GROUP_VIEW": "ฮคฯฮญฯ‡ฮฟฯ…ฯƒฮฑ ฯ€ฯฮฟฮฒฮฟฮปฮฎ", "GROUP_SECTION": "ฮ‘ฯ…ฯ„ฮฎ ฮท ฮตฮฝฯŒฯ„ฮทฯ„ฮฑ", - "GROUP_RECENT": "ฮ ฯฯŒฯƒฯ†ฮฑฯ„ฮฑ ฯ‡ฯฮทฯƒฮนฮผฮฟฯ€ฮฟฮนฮทฮผฮญฮฝฮตฯ‚" + "GROUP_RECENT": "ฮ ฯฯŒฯƒฯ†ฮฑฯ„ฮฑ ฯ‡ฯฮทฯƒฮนฮผฮฟฯ€ฮฟฮนฮทฮผฮญฮฝฮตฯ‚", + "GROUP_SETTINGS": "ฮกฯ…ฮธฮผฮฏฯƒฮตฮนฯ‚" }, "SHORTCUTS": { "TITLE": "ฮฃฯ…ฮฝฯ„ฮฟฮผฮตฯฯƒฮตฮนฯ‚ ฯ€ฮปฮทฮบฯ„ฯฮฟฮปฮฟฮณฮฏฮฟฯ…", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 6c99bdb4c..f4b30f4c9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "After repeated connection failures, pause new Xtream and Stalker requests for 30 seconds. Try disabling this if a portal becomes unavailable intermittently. Applies after saving, without restarting.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Each request will run independently. Unavailable servers may take longer to report an error.", "TITLE": "Settings", + "SEARCH_RESULTS_TITLE": "Search results", + "SEARCH_RESULTS_COUNT": "Settings matching โ€œ{{query}}โ€: {{count}}", + "SEARCH_EMPTY_HINT": "No settings match. Try another word, such as player, theme or EPG.", "GENERAL": "General", "GENERAL_SUBTITLE": "Change the configuration of the application", "PLAYBACK": "Playback", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Section actions", "SEARCH_LABEL": "Search", "SEARCH_SOURCES_PLACEHOLDER": "Search sources (all playlists)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Search settings...", "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Search in this playlist...", "SEARCH_SECTION_PLACEHOLDER": "Search in this section...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Command palette", "SUBTITLE": "Global and contextual actions", - "SEARCH_PLACEHOLDER": "Type to filter commands", + "SEARCH_PLACEHOLDER": "Search commands and settings", "SEARCH_LABEL": "Filter commands", "EMPTY": "No commands found", "GROUP_GLOBAL": "Global actions", "GROUP_PLAYLIST": "This playlist", "GROUP_VIEW": "Current view", "GROUP_SECTION": "This section", - "GROUP_RECENT": "Recently used" + "GROUP_RECENT": "Recently used", + "GROUP_SETTINGS": "Settings" }, "SHORTCUTS": { "TITLE": "Keyboard shortcuts", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c0a51add3..d86706364 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tras varios fallos de conexiรณn, pausar las nuevas solicitudes de Xtream y Stalker durante 30 segundos. Prueba a desactivarlo si un portal deja de estar disponible de forma intermitente. Se aplica al guardar, sin reiniciar.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada solicitud se ejecutarรก de forma independiente. Los servidores no disponibles pueden tardar mรกs en mostrar un error.", "TITLE": "Ajustes", + "SEARCH_RESULTS_TITLE": "Resultados de bรบsqueda", + "SEARCH_RESULTS_COUNT": "Ajustes que coinciden con ยซ{{query}}ยป: {{count}}", + "SEARCH_EMPTY_HINT": "Ningรบn ajuste coincide. Prueba con otra palabra, como reproductor, tema o EPG.", "GENERAL": "General", "GENERAL_SUBTITLE": "Cambia la configuracion general de la aplicacion", "PLAYBACK": "Reproduccion", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Acciones de la secciรณn", "SEARCH_LABEL": "Buscar", "SEARCH_SOURCES_PLACEHOLDER": "Buscar fuentes (todas las listas)โ€ฆ", + "SEARCH_SETTINGS_PLACEHOLDER": "Buscar en ajustesโ€ฆ", "SEARCH_GLOBAL_PLACEHOLDER": "Buscar en todas las listas...", "SEARCH_PLAYLIST_PLACEHOLDER": "Buscar en esta lista de reproducciรณnโ€ฆ", "SEARCH_SECTION_PLACEHOLDER": "Buscar en esta secciรณn...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Paleta de comandos", "SUBTITLE": "Acciones globales y contextuales", - "SEARCH_PLACEHOLDER": "Escribe para filtrar comandos", + "SEARCH_PLACEHOLDER": "Buscar comandos y ajustes", "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" + "GROUP_RECENT": "Usados recientemente", + "GROUP_SETTINGS": "Ajustes" }, "SHORTCUTS": { "TITLE": "Atajos de teclado", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4b014fc6e..24ad67d2b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Aprรจs plusieurs รฉchecs de connexion, suspendre les nouvelles requรชtes Xtream et Stalker pendant 30 secondes. Essayez de dรฉsactiver cette option si un portail devient indisponible par intermittence. Prend effet aprรจs enregistrement, sans redรฉmarrage.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Chaque requรชte sera exรฉcutรฉe indรฉpendamment. Les serveurs indisponibles peuvent mettre plus de temps ร  signaler une erreur.", "TITLE": "Paramรจtres", + "SEARCH_RESULTS_TITLE": "Rรฉsultats de la recherche", + "SEARCH_RESULTS_COUNT": "Paramรจtres correspondant ร  ยซ {{query}} ยป : {{count}}", + "SEARCH_EMPTY_HINT": "Aucun paramรจtre ne correspond. Essayez un autre mot, par exemple lecteur, thรจme ou EPG.", "GENERAL": "Gรฉnรฉral", "GENERAL_SUBTITLE": "Modifier la configuration de l'application", "PLAYBACK": "Lecture", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Actions de la section", "SEARCH_LABEL": "Rechercher", "SEARCH_SOURCES_PLACEHOLDER": "Rechercher des sources (toutes les listes)โ€ฆ", + "SEARCH_SETTINGS_PLACEHOLDER": "Rechercher dans les paramรจtresโ€ฆ", "SEARCH_GLOBAL_PLACEHOLDER": "Rechercher dans toutes les playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Rechercher dans cette liste de lectureโ€ฆ", "SEARCH_SECTION_PLACEHOLDER": "Rechercher dans cette section...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Palette de commandes", "SUBTITLE": "Actions globales et contextuelles", - "SEARCH_PLACEHOLDER": "Saisir pour filtrer les commandes", + "SEARCH_PLACEHOLDER": "Rechercher des commandes et des paramรจtres", "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" + "GROUP_RECENT": "Rรฉcemment utilisรฉes", + "GROUP_SETTINGS": "Paramรจtres" }, "SHORTCUTS": { "TITLE": "Raccourcis clavier", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 8da46cb54..f9e5d4afa 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Ismรฉtelt kapcsolรณdรกsi hibรกk utรกn az รบj Xtream- รฉs Stalker-kรฉrรฉsek szรผneteltetรฉse 30 mรกsodpercre. Ha egy portรกl idล‘nkรฉnt elรฉrhetetlennรฉ vรกlik, prรณbรกlja kikapcsolni. Mentรฉs utรกn, รบjraindรญtรกs nรฉlkรผl รฉrvรฉnyesรผl.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Minden kรฉrรฉs รถnรกllรณan fut. Elรฉrhetetlen szervereknรฉl a hiba megjelenรฉse tovรกbb tarthat.", "TITLE": "Beรกllรญtรกsok", + "SEARCH_RESULTS_TITLE": "Keresรฉsi talรกlatok", + "SEARCH_RESULTS_COUNT": "โ€ž{{query}}โ€ kifejezรฉsre illeszkedล‘ beรกllรญtรกsok: {{count}}", + "SEARCH_EMPTY_HINT": "Nincs illeszkedล‘ beรกllรญtรกs. Prรณbรกljon mรกsik szรณt, pรฉldรกul lejรกtszรณ, tรฉma vagy EPG.", "GENERAL": "รltalรกnos", "GENERAL_SUBTITLE": "Az alkalmazรกs beรกllรญtรกsainak mรณdosรญtรกsa", "PLAYBACK": "Lejรกtszรกs", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Szakaszmลฑveletek", "SEARCH_LABEL": "Keresรฉs", "SEARCH_SOURCES_PLACEHOLDER": "Keresรฉs a forrรกsok kรถzรถtt (รถsszes lejรกtszรกsi lista)โ€ฆ", + "SEARCH_SETTINGS_PLACEHOLDER": "Keresรฉs a beรกllรญtรกsokbanโ€ฆ", "SEARCH_GLOBAL_PLACEHOLDER": "Keresรฉs az รถsszes lejรกtszรกsi listรกbanโ€ฆ", "SEARCH_PLAYLIST_PLACEHOLDER": "Keresรฉs ebben a lejรกtszรกsi listรกbanโ€ฆ", "SEARCH_SECTION_PLACEHOLDER": "Keresรฉs ebben a szakaszbanโ€ฆ", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Parancspaletta", "SUBTITLE": "Globรกlis รฉs kรถrnyezetfรผggล‘ mลฑveletek", - "SEARCH_PLACEHOLDER": "A parancsok szลฑrรฉsรฉhez kezdjen gรฉpelni", + "SEARCH_PLACEHOLDER": "Parancsok รฉs beรกllรญtรกsok keresรฉse", "SEARCH_LABEL": "Parancsok szลฑrรฉse", "EMPTY": "Nincs talรกlat a parancsok kรถzรถtt", "GROUP_GLOBAL": "Globรกlis mลฑveletek", "GROUP_PLAYLIST": "Ez a lejรกtszรกsi lista", "GROUP_VIEW": "Aktuรกlis nรฉzet", "GROUP_SECTION": "Aktuรกlis szakasz", - "GROUP_RECENT": "Legutรณbb hasznรกlt" + "GROUP_RECENT": "Legutรณbb hasznรกlt", + "GROUP_SETTINGS": "Beรกllรญtรกsok" }, "SHORTCUTS": { "TITLE": "Billentyลฑparancsok", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 9bc6b53d7..3ee4f713d 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Dopo ripetuti errori di connessione, sospendi le nuove richieste Xtream e Stalker per 30 secondi. Prova a disattivare se un portale diventa intermittentemente non disponibile. Si applica al salvataggio, senza riavvio.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Ogni richiesta verrร  eseguita indipendentemente. I server non disponibili potrebbero impiegare piรน tempo a segnalare un errore.", "TITLE": "Impostazioni IPTVnator", + "SEARCH_RESULTS_TITLE": "Risultati della ricerca", + "SEARCH_RESULTS_COUNT": "Impostazioni corrispondenti a โ€œ{{query}}โ€: {{count}}", + "SEARCH_EMPTY_HINT": "Nessuna impostazione corrisponde. Prova un'altra parola, ad esempio riproduttore, tema o EPG.", "GENERAL": "Generale", "GENERAL_SUBTITLE": "Modifica le impostazioni generali dell'applicazione", "PLAYBACK": "Riproduzione", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Azioni sezione", "SEARCH_LABEL": "Cerca", "SEARCH_SOURCES_PLACEHOLDER": "Cerca sorgenti (tutte le playlist)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Cerca nelle impostazioni...", "SEARCH_GLOBAL_PLACEHOLDER": "Cerca in tutte le playlist...", "SEARCH_PLAYLIST_PLACEHOLDER": "Cerca in questa playlist...", "SEARCH_SECTION_PLACEHOLDER": "Cerca in questa sezione...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Tavolozza comandi", "SUBTITLE": "Azioni globali e contestuali", - "SEARCH_PLACEHOLDER": "Digita per filtrare i comandi", + "SEARCH_PLACEHOLDER": "Cerca comandi e impostazioni", "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" + "GROUP_RECENT": "Usati di recente", + "GROUP_SETTINGS": "Impostazioni" }, "SHORTCUTS": { "TITLE": "Scorciatoie da tastiera", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 971dd433f..d012458ef 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "ๆŽฅ็ถšใ‚จใƒฉใƒผใŒ็นฐใ‚Š่ฟ”ใ•ใ‚ŒใŸๅ ดๅˆใ€ๆ–ฐใ—ใ„ Xtream ใจ Stalker ใฎใƒชใ‚ฏใ‚จใ‚นใƒˆใ‚’30็ง’้–“ๅœๆญขใ—ใพใ™ใ€‚ใƒใƒผใ‚ฟใƒซใŒๆ–ญ็ถš็š„ใซๅˆฉ็”จใงใใชใใชใ‚‹ๅ ดๅˆใฏใ€็„กๅŠนใซใ—ใฆใฟใฆใใ ใ•ใ„ใ€‚ไฟๅญ˜ๅพŒใ€ๅ†่ตทๅ‹•ใ›ใšใซ้ฉ็”จใ•ใ‚Œใพใ™ใ€‚", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ๅ„ใƒชใ‚ฏใ‚จใ‚นใƒˆใฏๅ€‹ๅˆฅใซๅฎŸ่กŒใ•ใ‚Œใพใ™ใ€‚ใ‚ตใƒผใƒใƒผใŒๅˆฉ็”จใงใใชใ„ๅ ดๅˆใ€ใ‚จใƒฉใƒผใฎ่กจ็คบใซๆ™‚้–“ใŒใ‹ใ‹ใ‚‹ใ“ใจใŒใ‚ใ‚Šใพใ™ใ€‚", "TITLE": "่จญๅฎš", + "SEARCH_RESULTS_TITLE": "ๆคœ็ดข็ตๆžœ", + "SEARCH_RESULTS_COUNT": "ใ€Œ{{query}}ใ€ใซไธ€่‡ดใ™ใ‚‹่จญๅฎš: {{count}}", + "SEARCH_EMPTY_HINT": "ไธ€่‡ดใ™ใ‚‹่จญๅฎšใŒใ‚ใ‚Šใพใ›ใ‚“ใ€‚ใ€Œใƒ—ใƒฌใƒผใƒคใƒผใ€ใ€Œใƒ†ใƒผใƒžใ€ใ€ŒEPGใ€ใชใฉๅˆฅใฎ่จ€่‘‰ใ‚’่ฉฆใ—ใฆใใ ใ•ใ„ใ€‚", "GENERAL": "ไธ€่ˆฌ", "GENERAL_SUBTITLE": "ใ‚ขใƒ—ใƒชใ‚ฑใƒผใ‚ทใƒงใƒณใฎ่จญๅฎšใ‚’ๅค‰ๆ›ด", "PLAYBACK": "ๅ†็”Ÿ", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆๆ“ไฝœ", "SEARCH_LABEL": "ๆคœ็ดข", "SEARCH_SOURCES_PLACEHOLDER": "ใ‚ฝใƒผใ‚นใ‚’ๆคœ็ดข๏ผˆใ™ในใฆใฎใƒ—ใƒฌใ‚คใƒชใ‚นใƒˆ๏ผ‰...", + "SEARCH_SETTINGS_PLACEHOLDER": "่จญๅฎšใ‚’ๆคœ็ดข...", "SEARCH_GLOBAL_PLACEHOLDER": "ใ™ในใฆใฎใƒ—ใƒฌใ‚คใƒชใ‚นใƒˆใ‚’ๆคœ็ดข...", "SEARCH_PLAYLIST_PLACEHOLDER": "ใ“ใฎใƒ—ใƒฌใ‚คใƒชใ‚นใƒˆๅ†…ใ‚’ๆคœ็ดข...", "SEARCH_SECTION_PLACEHOLDER": "ใ“ใฎใ‚ปใ‚ฏใ‚ทใƒงใƒณๅ†…ใ‚’ๆคœ็ดข...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ใ‚ณใƒžใƒณใƒ‰ใƒ‘ใƒฌใƒƒใƒˆ", "SUBTITLE": "ใ‚ฐใƒญใƒผใƒใƒซใŠใ‚ˆใณใ‚ณใƒณใƒ†ใ‚ญใ‚นใƒˆใ‚ขใ‚ฏใ‚ทใƒงใƒณ", - "SEARCH_PLACEHOLDER": "ใ‚ณใƒžใƒณใƒ‰ใ‚’็ตžใ‚Š่พผใ‚€ใซใฏๅ…ฅๅŠ›", + "SEARCH_PLACEHOLDER": "ใ‚ณใƒžใƒณใƒ‰ใจ่จญๅฎšใ‚’ๆคœ็ดข", "SEARCH_LABEL": "ใ‚ณใƒžใƒณใƒ‰ใ‚’็ตžใ‚Š่พผใ‚€", "EMPTY": "ใ‚ณใƒžใƒณใƒ‰ใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", "GROUP_GLOBAL": "ใ‚ฐใƒญใƒผใƒใƒซใ‚ขใ‚ฏใ‚ทใƒงใƒณ", "GROUP_PLAYLIST": "ใ“ใฎใƒ—ใƒฌใ‚คใƒชใ‚นใƒˆ", "GROUP_VIEW": "็พๅœจใฎใƒ“ใƒฅใƒผ", "GROUP_SECTION": "็พๅœจใฎใ‚ปใ‚ฏใ‚ทใƒงใƒณ", - "GROUP_RECENT": "ๆœ€่ฟ‘ไฝฟ็”จ" + "GROUP_RECENT": "ๆœ€่ฟ‘ไฝฟ็”จ", + "GROUP_SETTINGS": "่จญๅฎš" }, "SHORTCUTS": { "TITLE": "ใ‚ญใƒผใƒœใƒผใƒ‰ใ‚ทใƒงใƒผใƒˆใ‚ซใƒƒใƒˆ", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d98dc32c7..7f2753c00 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "์—ฐ๊ฒฐ ์˜ค๋ฅ˜๊ฐ€ ๋ฐ˜๋ณต๋˜๋ฉด ์ƒˆ Xtream ๋ฐ Stalker ์š”์ฒญ์„ 30์ดˆ ๋™์•ˆ ์ค‘์ง€ํ•ฉ๋‹ˆ๋‹ค. ํฌํ„ธ์„ ๊ฐ„ํ—์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค๋ฉด ์ด ์˜ต์…˜์„ ๊บผ ๋ณด์„ธ์š”. ์ €์žฅ ํ›„ ์žฌ์‹œ์ž‘ ์—†์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "๊ฐ ์š”์ฒญ์ด ๋…๋ฆฝ์ ์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ์„œ๋ฒ„๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์œผ๋ฉด ์˜ค๋ฅ˜๊ฐ€ ํ‘œ์‹œ๋˜๊ธฐ๊นŒ์ง€ ๋” ์˜ค๋ž˜ ๊ฑธ๋ฆด ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", "TITLE": "์„ค์ •", + "SEARCH_RESULTS_TITLE": "๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ", + "SEARCH_RESULTS_COUNT": "โ€œ{{query}}โ€์™€ ์ผ์น˜ํ•˜๋Š” ์„ค์ •: {{count}}", + "SEARCH_EMPTY_HINT": "์ผ์น˜ํ•˜๋Š” ์„ค์ •์ด ์—†์Šต๋‹ˆ๋‹ค. ํ”Œ๋ ˆ์ด์–ด, ํ…Œ๋งˆ, EPG ๊ฐ™์€ ๋‹ค๋ฅธ ๋‹จ์–ด๋กœ ๊ฒ€์ƒ‰ํ•ด ๋ณด์„ธ์š”.", "GENERAL": "์ผ๋ฐ˜", "GENERAL_SUBTITLE": "์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ตฌ์„ฑ์„ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค", "PLAYBACK": "์žฌ์ƒ", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "์ปจํ…์ŠคํŠธ ์ž‘์—…", "SEARCH_LABEL": "๊ฒ€์ƒ‰", "SEARCH_SOURCES_PLACEHOLDER": "์†Œ์Šค ๊ฒ€์ƒ‰ (๋ชจ๋“  ์žฌ์ƒ๋ชฉ๋ก)...", + "SEARCH_SETTINGS_PLACEHOLDER": "์„ค์ • ๊ฒ€์ƒ‰...", "SEARCH_GLOBAL_PLACEHOLDER": "๋ชจ๋“  ํ”Œ๋ ˆ์ด๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰...", "SEARCH_PLAYLIST_PLACEHOLDER": "์ด ์žฌ์ƒ๋ชฉ๋ก์—์„œ ๊ฒ€์ƒ‰...", "SEARCH_SECTION_PLACEHOLDER": "์ด ์„น์…˜์—์„œ ๊ฒ€์ƒ‰...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "๋ช…๋ น์–ด ํŒ”๋ ˆํŠธ", "SUBTITLE": "์ „์—ญ ๋ฐ ์ƒํ™ฉ๋ณ„ ์ž‘์—…", - "SEARCH_PLACEHOLDER": "๋ช…๋ น์–ด๋ฅผ ํ•„ํ„ฐ๋งํ•˜๋ ค๋ฉด ์ž…๋ ฅํ•˜์„ธ์š”", + "SEARCH_PLACEHOLDER": "๋ช…๋ น์–ด์™€ ์„ค์ • ๊ฒ€์ƒ‰", "SEARCH_LABEL": "๋ช…๋ น์–ด ํ•„ํ„ฐ", "EMPTY": "๋ช…๋ น์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", "GROUP_GLOBAL": "์ „์—ญ ์ž‘์—…", "GROUP_PLAYLIST": "์ด ์žฌ์ƒ๋ชฉ๋ก", "GROUP_VIEW": "ํ˜„์žฌ ํ™”๋ฉด", "GROUP_SECTION": "ํ˜„์žฌ ์„น์…˜", - "GROUP_RECENT": "์ตœ๊ทผ ์‚ฌ์šฉ" + "GROUP_RECENT": "์ตœ๊ทผ ์‚ฌ์šฉ", + "GROUP_SETTINGS": "์„ค์ •" }, "SHORTCUTS": { "TITLE": "ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index cb2737e25..9b17ba524 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Pauzeer nieuwe Xtream- en Stalker-verzoeken 30 seconden na herhaalde verbindingsfouten. Probeer dit uit te schakelen als een portal af en toe onbereikbaar is. Geldt na opslaan, zonder herstart.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Elk verzoek wordt onafhankelijk uitgevoerd. Bij onbereikbare servers kan een foutmelding langer duren.", "TITLE": "Instellingen", + "SEARCH_RESULTS_TITLE": "Zoekresultaten", + "SEARCH_RESULTS_COUNT": "Instellingen voor โ€œ{{query}}โ€: {{count}}", + "SEARCH_EMPTY_HINT": "Geen instellingen gevonden. Probeer een ander woord, zoals speler, thema of EPG.", "GENERAL": "Algemeen", "GENERAL_SUBTITLE": "Wijzig de configuratie van de applicatie", "PLAYBACK": "Afspelen", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Sectieacties", "SEARCH_LABEL": "Zoeken", "SEARCH_SOURCES_PLACEHOLDER": "Zoek bronnen (alle afspeellijsten)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Zoek in instellingen...", "SEARCH_GLOBAL_PLACEHOLDER": "Alle afspeellijsten doorzoeken...", "SEARCH_PLAYLIST_PLACEHOLDER": "Zoek in deze afspeellijst...", "SEARCH_SECTION_PLACEHOLDER": "Zoeken in deze sectie...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Opdrachtenpalet", "SUBTITLE": "Algemene en contextuele acties", - "SEARCH_PLACEHOLDER": "Typ om opdrachten te filteren", + "SEARCH_PLACEHOLDER": "Zoek opdrachten en instellingen", "SEARCH_LABEL": "Filter opdrachten", "EMPTY": "Geen opdrachten gevonden", "GROUP_GLOBAL": "Algemene acties", "GROUP_PLAYLIST": "Deze afspeellijst", "GROUP_VIEW": "Huidige weergave", "GROUP_SECTION": "Deze sectie", - "GROUP_RECENT": "Recent gebruikt" + "GROUP_RECENT": "Recent gebruikt", + "GROUP_SETTINGS": "Instellingen" }, "SHORTCUTS": { "TITLE": "Sneltoetsen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 3456e2102..2f99542fb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Po powtarzajฤ…cych siฤ™ bล‚ฤ™dach poล‚ฤ…czenia wstrzymuj nowe ลผฤ…dania Xtream i Stalker na 30 sekund. Sprรณbuj wyล‚ฤ…czyฤ‡, jeล›li portal bywa niedostฤ™pny. Zmiana dziaล‚a po zapisaniu, bez ponownego uruchomienia.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Kaลผde ลผฤ…danie bฤ™dzie wykonywane niezaleลผnie. Niedostฤ™pne serwery mogฤ… zgล‚aszaฤ‡ bล‚ฤ…d z wiฤ™kszym opรณลบnieniem.", "TITLE": "Ustawienia", + "SEARCH_RESULTS_TITLE": "Wyniki wyszukiwania", + "SEARCH_RESULTS_COUNT": "Ustawienia pasujฤ…ce do โ€ž{{query}}โ€: {{count}}", + "SEARCH_EMPTY_HINT": "Brak pasujฤ…cych ustawieล„. Sprรณbuj innego sล‚owa, np. odtwarzacz, motyw lub EPG.", "GENERAL": "Ogรณlne", "GENERAL_SUBTITLE": "Zmieล„ konfiguracjฤ™ aplikacji", "PLAYBACK": "Odtwarzanie", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Akcje sekcji", "SEARCH_LABEL": "Szukaj", "SEARCH_SOURCES_PLACEHOLDER": "Szukaj ลบrรณdeล‚ (wszystkie listy odtwarzania)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Szukaj w ustawieniach...", "SEARCH_GLOBAL_PLACEHOLDER": "Przeszukaj wszystkie listy odtwarzania...", "SEARCH_PLAYLIST_PLACEHOLDER": "Szukaj na tej liล›cie odtwarzania...", "SEARCH_SECTION_PLACEHOLDER": "Szukaj w tej sekcji...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Paleta poleceล„", "SUBTITLE": "Akcje globalne i kontekstowe", - "SEARCH_PLACEHOLDER": "Wpisz, aby filtrowaฤ‡ polecenia", + "SEARCH_PLACEHOLDER": "Szukaj poleceล„ i ustawieล„", "SEARCH_LABEL": "Filtruj polecenia", "EMPTY": "Nie znaleziono poleceล„", "GROUP_GLOBAL": "Akcje globalne", "GROUP_PLAYLIST": "Ta lista odtwarzania", "GROUP_VIEW": "Bieลผฤ…cy widok", "GROUP_SECTION": "Ta sekcja", - "GROUP_RECENT": "Ostatnio uลผywane" + "GROUP_RECENT": "Ostatnio uลผywane", + "GROUP_SETTINGS": "Ustawienia" }, "SHORTCUTS": { "TITLE": "Skrรณty klawiaturowe", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e48fde94a..e48d01536 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Apรณs falhas repetidas de ligaรงรฃo, pausar novos pedidos Xtream e Stalker durante 30 segundos. Experimente desativar se um portal ficar indisponรญvel de forma intermitente. Aplica-se ao guardar, sem reiniciar.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada pedido serรก executado de forma independente. Os servidores indisponรญveis podem demorar mais a indicar um erro.", "TITLE": "Configuraรงรตes", + "SEARCH_RESULTS_TITLE": "Resultados da pesquisa", + "SEARCH_RESULTS_COUNT": "Configuraรงรตes correspondentes a โ€œ{{query}}โ€: {{count}}", + "SEARCH_EMPTY_HINT": "Nenhuma configuraรงรฃo corresponde. Tente outra palavra, como reprodutor, tema ou EPG.", "GENERAL": "Geral", "GENERAL_SUBTITLE": "Altere a configuraรงรฃo do aplicativo", "PLAYBACK": "Reproduรงรฃo", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Aรงรตes de contexto", "SEARCH_LABEL": "Pesquisar", "SEARCH_SOURCES_PLACEHOLDER": "Pesquisar fontes (todas as playlists)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Pesquisar nas configuraรงรตes...", "SEARCH_GLOBAL_PLACEHOLDER": "Pesquisar todas as playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Pesquisar nesta playlist...", "SEARCH_SECTION_PLACEHOLDER": "Pesquisar nesta seรงรฃo...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Paleta de comandos", "SUBTITLE": "Aรงรตes globais e contextuais", - "SEARCH_PLACEHOLDER": "Digite para filtrar comandos", + "SEARCH_PLACEHOLDER": "Pesquisar comandos e configuraรงรตes", "SEARCH_LABEL": "Filtrar comandos", "EMPTY": "Nenhum comando encontrado", "GROUP_GLOBAL": "Aรงรตes globais", "GROUP_PLAYLIST": "Esta playlist", "GROUP_VIEW": "Visualizaรงรฃo atual", "GROUP_SECTION": "Esta seรงรฃo", - "GROUP_RECENT": "Usados recentemente" + "GROUP_RECENT": "Usados recentemente", + "GROUP_SETTINGS": "Configuraรงรตes" }, "SHORTCUTS": { "TITLE": "Atalhos de teclado", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index ea575277c..b9b28c997 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "ะŸะพัะปะต ะฟะพะฒั‚ะพั€ะฝั‹ั… ะพัˆะธะฑะพะบ ะฟะพะดะบะปัŽั‡ะตะฝะธั ะฟั€ะธะพัั‚ะฐะฝะฐะฒะปะธะฒะฐั‚ัŒ ะฝะพะฒั‹ะต ะทะฐะฟั€ะพัั‹ Xtream ะธ Stalker ะฝะฐ 30 ัะตะบัƒะฝะด. ะŸะพะฟั€ะพะฑัƒะนั‚ะต ะพั‚ะบะปัŽั‡ะธั‚ัŒ, ะตัะปะธ ะฟะพั€ั‚ะฐะป ะฟะตั€ะธะพะดะธั‡ะตัะบะธ ัั‚ะฐะฝะพะฒะธั‚ัั ะฝะตะดะพัั‚ัƒะฟะตะฝ. ะŸั€ะธะผะตะฝัะตั‚ัั ะฟะพัะปะต ัะพั…ั€ะฐะฝะตะฝะธั ะฑะตะท ะฟะตั€ะตะทะฐะฟัƒัะบะฐ.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ะšะฐะถะดั‹ะน ะทะฐะฟั€ะพั ะฑัƒะดะตั‚ ะฒั‹ะฟะพะปะฝัั‚ัŒัั ะฝะตะทะฐะฒะธัะธะผะพ. ะ•ัะปะธ ัะตั€ะฒะตั€ ะฝะตะดะพัั‚ัƒะฟะตะฝ, ะพะถะธะดะฐะฝะธะต ะพัˆะธะฑะบะธ ะผะพะถะตั‚ ัƒะฒะตะปะธั‡ะธั‚ัŒัั.", "TITLE": "ะะฐัั‚ั€ะพะนะบะธ", + "SEARCH_RESULTS_TITLE": "ะ ะตะทัƒะปัŒั‚ะฐั‚ั‹ ะฟะพะธัะบะฐ", + "SEARCH_RESULTS_COUNT": "ะะฐัั‚ั€ะพะนะบะธ ะฟะพ ะทะฐะฟั€ะพััƒ ยซ{{query}}ยป: {{count}}", + "SEARCH_EMPTY_HINT": "ะะธั‡ะตะณะพ ะฝะต ะฝะฐะนะดะตะฝะพ. ะŸะพะฟั€ะพะฑัƒะนั‚ะต ะดั€ัƒะณะพะต ัะปะพะฒะพ, ะฝะฐะฟั€ะธะผะตั€ ยซะฟะปะตะตั€ยป, ยซั‚ะตะผะฐยป ะธะปะธ ยซEPGยป.", "GENERAL": "ะžะฑั‰ะธะต", "GENERAL_SUBTITLE": "ะ˜ะทะผะตะฝะตะฝะธะต ะพัะฝะพะฒะฝั‹ั… ะฟะฐั€ะฐะผะตั‚ั€ะพะฒ ะฟั€ะธะปะพะถะตะฝะธั", "PLAYBACK": "ะ’ะพัะฟั€ะพะธะทะฒะตะดะตะฝะธะต", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ะ”ะตะนัั‚ะฒะธั ั€ะฐะทะดะตะปะฐ", "SEARCH_LABEL": "ะŸะพะธัะบ", "SEARCH_SOURCES_PLACEHOLDER": "ะ˜ัะบะฐั‚ัŒ ะธัั‚ะพั‡ะฝะธะบะธ (ะฒัะต ะฟะปะตะนะปะธัั‚ั‹)...", + "SEARCH_SETTINGS_PLACEHOLDER": "ะ˜ัะบะฐั‚ัŒ ะฒ ะฝะฐัั‚ั€ะพะนะบะฐั…...", "SEARCH_GLOBAL_PLACEHOLDER": "ะ˜ัะบะฐั‚ัŒ ะฒะพ ะฒัะตั… ะฟะปะตะนะปะธัั‚ะฐั…...", "SEARCH_PLAYLIST_PLACEHOLDER": "ะ˜ัะบะฐั‚ัŒ ะฒ ัั‚ะพะผ ะฟะปะตะนะปะธัั‚ะต...", "SEARCH_SECTION_PLACEHOLDER": "ะ˜ัะบะฐั‚ัŒ ะฒ ัั‚ะพะผ ั€ะฐะทะดะตะปะต...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ะŸะฐะปะธั‚ั€ะฐ ะบะพะผะฐะฝะด", "SUBTITLE": "ะ“ะปะพะฑะฐะปัŒะฝั‹ะต ะธ ะบะพะฝั‚ะตะบัั‚ะฝั‹ะต ะดะตะนัั‚ะฒะธั", - "SEARCH_PLACEHOLDER": "ะ’ะฒะตะดะธั‚ะต ั‚ะตะบัั‚ ะดะปั ั„ะธะปัŒั‚ั€ะฐั†ะธะธ ะบะพะผะฐะฝะด", + "SEARCH_PLACEHOLDER": "ะ˜ัะบะฐั‚ัŒ ะบะพะผะฐะฝะดั‹ ะธ ะฝะฐัั‚ั€ะพะนะบะธ", "SEARCH_LABEL": "ะคะธะปัŒั‚ั€ะพะฒะฐั‚ัŒ ะบะพะผะฐะฝะดั‹", "EMPTY": "ะšะพะผะฐะฝะดั‹ ะฝะต ะฝะฐะนะดะตะฝั‹", "GROUP_GLOBAL": "ะ“ะปะพะฑะฐะปัŒะฝั‹ะต ะดะตะนัั‚ะฒะธั", "GROUP_PLAYLIST": "ะญั‚ะพั‚ ะฟะปะตะนะปะธัั‚", "GROUP_VIEW": "ะขะตะบัƒั‰ะตะต ะฟั€ะตะดัั‚ะฐะฒะปะตะฝะธะต", "GROUP_SECTION": "ะญั‚ะพั‚ ั€ะฐะทะดะตะป", - "GROUP_RECENT": "ะะตะดะฐะฒะฝะพ ะธัะฟะพะปัŒะทะพะฒะฐะฝะฝั‹ะต" + "GROUP_RECENT": "ะะตะดะฐะฒะฝะพ ะธัะฟะพะปัŒะทะพะฒะฐะฝะฝั‹ะต", + "GROUP_SETTINGS": "ะะฐัั‚ั€ะพะนะบะธ" }, "SHORTCUTS": { "TITLE": "ะกะพั‡ะตั‚ะฐะฝะธั ะบะปะฐะฒะธัˆ", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6bbcf920d..b5aa4393a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tekrarlanan baฤŸlantฤฑ hatalarฤฑndan sonra yeni Xtream ve Stalker isteklerini 30 saniye duraklatฤฑr. Portal zaman zaman kullanฤฑlamฤฑyorsa kapatmayฤฑ deneyin. Kaydettikten sonra yeniden baลŸlatmadan uygulanฤฑr.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Her istek baฤŸฤฑmsฤฑz yรผrรผtรผlรผr. Kullanฤฑlamayan sunucularda hata bildirimi daha uzun sรผrebilir.", "TITLE": "Ayarlar", + "SEARCH_RESULTS_TITLE": "Arama sonuรงlarฤฑ", + "SEARCH_RESULTS_COUNT": "โ€œ{{query}}โ€ ile eลŸleลŸen ayarlar: {{count}}", + "SEARCH_EMPTY_HINT": "EลŸleลŸen ayar yok. Oynatฤฑcฤฑ, tema veya EPG gibi baลŸka bir kelime deneyin.", "GENERAL": "Genel", "GENERAL_SUBTITLE": "Uygulamanฤฑn yapฤฑlandฤฑrmasฤฑnฤฑ deฤŸiลŸtirin", "PLAYBACK": "Oynatma", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Bรถlรผm eylemleri", "SEARCH_LABEL": "Ara", "SEARCH_SOURCES_PLACEHOLDER": "Kaynaklarda ara (tรผm oynatma listeleri)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Ayarlarda ara...", "SEARCH_GLOBAL_PLACEHOLDER": "Tรผm oynatma listelerinde ara...", "SEARCH_PLAYLIST_PLACEHOLDER": "Bu oynatma listesinde ara...", "SEARCH_SECTION_PLACEHOLDER": "Bu bรถlรผmde ara...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Komut paleti", "SUBTITLE": "Genel ve baฤŸlamsal iลŸlemler", - "SEARCH_PLACEHOLDER": "Komutlarฤฑ filtrelemek iรงin yazฤฑn", + "SEARCH_PLACEHOLDER": "Komutlarda ve ayarlarda ara", "SEARCH_LABEL": "Komutlarฤฑ filtrele", "EMPTY": "Komut bulunamadฤฑ", "GROUP_GLOBAL": "Genel iลŸlemler", "GROUP_PLAYLIST": "Bu oynatma listesi", "GROUP_VIEW": "Geรงerli gรถrรผnรผm", "GROUP_SECTION": "Bu bรถlรผm", - "GROUP_RECENT": "Son kullanฤฑlanlar" + "GROUP_RECENT": "Son kullanฤฑlanlar", + "GROUP_SETTINGS": "Ayarlar" }, "SHORTCUTS": { "TITLE": "Klavye kฤฑsayollarฤฑ", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e289f60c5..368a45c8a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "่ฟžๆŽฅ่ฟž็ปญๅคฑ่ดฅๅŽ๏ผŒๆš‚ๅœๆ–ฐ็š„ Xtream ๅ’Œ Stalker ่ฏทๆฑ‚ 30 ็ง’ใ€‚ๅฆ‚ๆžœ้—จๆˆท้—ดๆญ‡ๆ€งไธๅฏ็”จ๏ผŒ่ฏทๅฐ่ฏ•ๅ…ณ้—ญๆญค้€‰้กนใ€‚ไฟๅญ˜ๅŽ็”Ÿๆ•ˆ๏ผŒๆ— ้œ€้‡ๅฏใ€‚", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ๆฏไธช่ฏทๆฑ‚ๅฐ†็‹ฌ็ซ‹ๆ‰ง่กŒใ€‚ๆœๅŠกๅ™จไธๅฏ็”จๆ—ถ๏ผŒๅฏ่ƒฝ้œ€่ฆๆ›ด้•ฟๆ—ถ้—ดๆ‰่ƒฝๆ˜พ็คบ้”™่ฏฏใ€‚", "TITLE": "่ฎพ็ฝฎ", + "SEARCH_RESULTS_TITLE": "ๆœ็ดข็ป“ๆžœ", + "SEARCH_RESULTS_COUNT": "ไธŽโ€œ{{query}}โ€ๅŒน้…็š„่ฎพ็ฝฎ๏ผš{{count}}", + "SEARCH_EMPTY_HINT": "ๆฒกๆœ‰ๅŒน้…็š„่ฎพ็ฝฎใ€‚่ฏ•่ฏ•ๅ…ถไป–่ฏ๏ผŒไพ‹ๅฆ‚โ€œๆ’ญๆ”พๅ™จโ€โ€œไธป้ข˜โ€ๆˆ–โ€œEPGโ€ใ€‚", "GENERAL": "้€š็”จ", "GENERAL_SUBTITLE": "ๆ›ดๆ”นๅบ”็”จ็จ‹ๅบ็š„้…็ฝฎ", "PLAYBACK": "ๆ’ญๆ”พ", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ไธŠไธ‹ๆ–‡ๆ“ไฝœ", "SEARCH_LABEL": "ๆœ็ดข", "SEARCH_SOURCES_PLACEHOLDER": "ๆœ็ดขๆบ๏ผˆๆ‰€ๆœ‰ๆ’ญๆ”พๅˆ—่กจ๏ผ‰โ€ฆ", + "SEARCH_SETTINGS_PLACEHOLDER": "ๆœ็ดข่ฎพ็ฝฎโ€ฆ", "SEARCH_GLOBAL_PLACEHOLDER": "ๆœ็ดขๆ‰€ๆœ‰ๆ’ญๆ”พๅˆ—่กจโ€ฆ", "SEARCH_PLAYLIST_PLACEHOLDER": "ๅœจๆญคๆ’ญๆ”พๅˆ—่กจไธญๆœ็ดขโ€ฆ", "SEARCH_SECTION_PLACEHOLDER": "ๅœจๆญคๅŒบๅŸŸไธญๆœ็ดขโ€ฆ", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ๅ‘ฝไปค้ขๆฟ", "SUBTITLE": "ๅ…จๅฑ€ๅ’ŒไธŠไธ‹ๆ–‡ๆ“ไฝœ", - "SEARCH_PLACEHOLDER": "่พ“ๅ…ฅไปฅ็ญ›้€‰ๅ‘ฝไปค", + "SEARCH_PLACEHOLDER": "ๆœ็ดขๅ‘ฝไปคๅ’Œ่ฎพ็ฝฎ", "SEARCH_LABEL": "็ญ›้€‰ๅ‘ฝไปค", "EMPTY": "ๆœชๆ‰พๅˆฐๅ‘ฝไปค", "GROUP_GLOBAL": "ๅ…จๅฑ€ๆ“ไฝœ", "GROUP_PLAYLIST": "ๆœฌๆ’ญๆ”พๅˆ—่กจ", "GROUP_VIEW": "ๅฝ“ๅ‰่ง†ๅ›พ", "GROUP_SECTION": "ๅฝ“ๅ‰ๅŒบๅŸŸ", - "GROUP_RECENT": "ๆœ€่ฟ‘ไฝฟ็”จ" + "GROUP_RECENT": "ๆœ€่ฟ‘ไฝฟ็”จ", + "GROUP_SETTINGS": "่ฎพ็ฝฎ" }, "SHORTCUTS": { "TITLE": "้”ฎ็›˜ๅฟซๆท้”ฎ", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ed991534f..b701b6160 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "้€ฃ็ทš้€ฃ็บŒๅคฑๆ•—ๅพŒ๏ผŒๆšซๅœๆ–ฐ็š„ Xtream ๅ’Œ Stalker ่ซ‹ๆฑ‚ 30 ็ง’ใ€‚ๅฆ‚ๆžœๅ…ฅๅฃ็ถฒ็ซ™้–“ๆญ‡ๆ€ง็„กๆณ•ไฝฟ็”จ๏ผŒ่ซ‹ๅ˜—่ฉฆ้—œ้–‰ๆญค้ธ้ …ใ€‚ๅ„ฒๅญ˜ๅพŒ็”Ÿๆ•ˆ๏ผŒ็„ก้œ€้‡ๆ–ฐๅ•Ÿๅ‹•ใ€‚", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "ๆฏๅ€‹่ซ‹ๆฑ‚ๅฐ‡็จ็ซ‹ๅŸท่กŒใ€‚ไผบๆœๅ™จ็„กๆณ•ไฝฟ็”จๆ™‚๏ผŒๅฏ่ƒฝ้œ€่ฆๆ›ด้•ทๆ™‚้–“ๆ‰่ƒฝ้กฏ็คบ้Œฏ่ชคใ€‚", "TITLE": "่จญๅฎš", + "SEARCH_RESULTS_TITLE": "ๆœๅฐ‹็ตๆžœ", + "SEARCH_RESULTS_COUNT": "็ฌฆๅˆใ€Œ{{query}}ใ€็š„่จญๅฎš๏ผš{{count}}", + "SEARCH_EMPTY_HINT": "ๆฒ’ๆœ‰็ฌฆๅˆ็š„่จญๅฎšใ€‚่ฉฆ่ฉฆๅ…ถไป–่ฉž๏ผŒไพ‹ๅฆ‚ใ€Œๆ’ญๆ”พๅ™จใ€ใ€Œไธป้กŒใ€ๆˆ–ใ€ŒEPGใ€ใ€‚", "GENERAL": "ไธ€่ˆฌ", "GENERAL_SUBTITLE": "ๆ›ดๆ”นๆ‡‰็”จ็จ‹ๅผ็š„้…็ฝฎ", "PLAYBACK": "ๆ’ญๆ”พ", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "ๅ€ๆฎตๆ“ไฝœ", "SEARCH_LABEL": "ๆœๅฐ‹", "SEARCH_SOURCES_PLACEHOLDER": "ๆœๅฐ‹ไพ†ๆบ๏ผˆๆ‰€ๆœ‰ๆ’ญๆ”พๆธ…ๅ–ฎ๏ผ‰...", + "SEARCH_SETTINGS_PLACEHOLDER": "ๆœๅฐ‹่จญๅฎš...", "SEARCH_GLOBAL_PLACEHOLDER": "ๆœๅฐ‹ๆ‰€ๆœ‰ๆ’ญๆ”พๆธ…ๅ–ฎโ€ฆ", "SEARCH_PLAYLIST_PLACEHOLDER": "ๅœจๆญคๆ’ญๆ”พๆธ…ๅ–ฎไธญๆœๅฐ‹...", "SEARCH_SECTION_PLACEHOLDER": "ๅœจๆญคๅ€ๆฎตไธญๆœๅฐ‹...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "ๅ‘ฝไปค้ธๆ“‡ๅ€", "SUBTITLE": "ๅ…จๅŸŸ่ˆ‡ๆƒ…ๅขƒๅŒ–ๆ“ไฝœ", - "SEARCH_PLACEHOLDER": "่ผธๅ…ฅไปฅ็ฏฉ้ธๅ‘ฝไปค", + "SEARCH_PLACEHOLDER": "ๆœๅฐ‹ๅ‘ฝไปคๅ’Œ่จญๅฎš", "SEARCH_LABEL": "็ฏฉ้ธๅ‘ฝไปค", "EMPTY": "ๆ‰พไธๅˆฐๅ‘ฝไปค", "GROUP_GLOBAL": "ๅ…จๅŸŸๆ“ไฝœ", "GROUP_PLAYLIST": "ๆญคๆ’ญๆ”พๆธ…ๅ–ฎ", "GROUP_VIEW": "็›ฎๅ‰็•ซ้ข", "GROUP_SECTION": "ๆญคๅ€ๆฎต", - "GROUP_RECENT": "ๆœ€่ฟ‘ไฝฟ็”จ" + "GROUP_RECENT": "ๆœ€่ฟ‘ไฝฟ็”จ", + "GROUP_SETTINGS": "่จญๅฎš" }, "SHORTCUTS": { "TITLE": "้ต็›คๅฟซๆท้ต", diff --git a/apps/website/README.md b/apps/website/README.md index 9ede7d72b..7754a7b35 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -108,6 +108,16 @@ skip shows up in your output. ## Guides +`/guides/` is the task-based entry point: Getting started, Live TV & EPG, +Player & playback, Your library and Updates. `src/lib/guides.ts` owns the +reading order; titles and descriptions come from the existing blog collection, +and article URLs stay at `/blog//`. Add an article's slug to the matching +group when it should appear here. Missing slugs fail the build. Drafts follow +the blog's visibility rule (development mode or `PUBLIC_INCLUDE_DRAFTS=true`), +and preview entries carry a draft label. Updates also links to the latest +published release post. The hub emits CollectionPage and BreadcrumbList data +and is linked from the site header, footer and blog index. + Evergreen how-to posts live in the blog collection next to release notes (`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`, `m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx`, @@ -156,7 +166,10 @@ Evergreen how-to posts live in the blog collection next to release notes `tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks each guide for the FAQPage schema, a link to the download hub and the presence -of every referenced screenshot in the build output. +of every referenced screenshot in the build output. It also checks the task hub's +grouping, draft visibility, article links, structured data and navigation at +phone, tablet and desktop widths. Run the hub checks against a build made with +`PUBLIC_INCLUDE_DRAFTS=true` using the same environment flag to verify previews. ## Blog Tags diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index f9df7f5ba..634fd4062 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -6,6 +6,7 @@ - Website: https://4gray.github.io/iptvnator/ - Blog: https://4gray.github.io/iptvnator/blog/ +- Guides by task: https://4gray.github.io/iptvnator/guides/ - Latest Release: https://github.com/4gray/iptvnator/releases/latest - Downloads overview: https://4gray.github.io/iptvnator/download/ - Download for Windows: https://4gray.github.io/iptvnator/download/windows/ diff --git a/apps/website/src/components/Footer.astro b/apps/website/src/components/Footer.astro index e7edb422e..adc9926fc 100644 --- a/apps/website/src/components/Footer.astro +++ b/apps/website/src/components/Footer.astro @@ -27,6 +27,7 @@ const socialLinks = [ const footerLinks = [ { label: 'Features', href: '/iptvnator/features/' }, + { label: 'Guides', href: '/iptvnator/guides/' }, { label: 'Compare', href: '/iptvnator/compare/' }, { label: 'Download', href: '/iptvnator/download/' }, { label: 'Blog', href: '/iptvnator/blog/' }, diff --git a/apps/website/src/components/Header.astro b/apps/website/src/components/Header.astro index a53dbca87..a384d176f 100644 --- a/apps/website/src/components/Header.astro +++ b/apps/website/src/components/Header.astro @@ -1,6 +1,7 @@ --- const navLinks = [ { label: 'Features', href: '/iptvnator/features/' }, + { label: 'Guides', href: '/iptvnator/guides/' }, { label: 'Compare', href: '/iptvnator/compare/' }, { label: 'Screenshots', href: '/iptvnator/#screenshots' }, { label: 'Download', href: '/iptvnator/download/' }, @@ -28,12 +29,13 @@ const navLinks = [ -