mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/admiring-bell-7132d7
This commit is contained in:
commit
8eaa5fcf7c
127 files changed
+6071
-456
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../coverage/apps/web',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> {
|
||||
const status = this.status();
|
||||
const latestVersion = status?.latestVersion;
|
||||
let dialogModule: Awaited<
|
||||
ReturnType<typeof this.loadReleaseNotesDialog>
|
||||
>;
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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<LazyStalkerPlaylistConnectionEditor['loadEditorModule']>
|
||||
>;
|
||||
|
||||
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<Promise<EditorModule>, []>()
|
||||
.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);
|
||||
});
|
||||
});
|
||||
@@ -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<StalkerPlaylistConnectionEditor> | null = null;
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadEditorModule: () => Promise<EditorModule> = () =>
|
||||
import('./stalker-playlist-connection-editor.service');
|
||||
|
||||
async resolveConnection(
|
||||
playlist: PlaylistMeta,
|
||||
sourcePlaylist?: PlaylistMeta
|
||||
): Promise<StalkerPlaylistConnectionResult> {
|
||||
const editor = await this.resolveEditor();
|
||||
return editor.resolveConnection(playlist, sourcePlaylist);
|
||||
}
|
||||
|
||||
async applyResolvedConnection(
|
||||
playlist: PlaylistMetaUpdate,
|
||||
options?: StalkerResolvedConnectionApplyOptions
|
||||
): Promise<PlaylistMetaUpdate> {
|
||||
const editor = await this.resolveEditor();
|
||||
return editor.applyResolvedConnection(playlist, options);
|
||||
}
|
||||
|
||||
private resolveEditor(): Promise<StalkerPlaylistConnectionEditor> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<ExternalPlayerSession | void> {
|
||||
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<ExternalPlayerSession>(
|
||||
ipcEvent,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="version">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
|
||||
@@ -31,7 +31,10 @@
|
||||
</div>
|
||||
|
||||
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
|
||||
<div class="setting-item setting-item--full-control app-update-panel">
|
||||
<div
|
||||
class="setting-item setting-item--full-control app-update-panel"
|
||||
data-setting-id="app-update"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}</p>
|
||||
@@ -263,7 +266,10 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="tmdb-credits"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CREDITS' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
|
||||
@@ -281,7 +287,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="community"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}</p>
|
||||
@@ -364,7 +373,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="support"
|
||||
>
|
||||
<div class="support-card" data-test-id="support-card">
|
||||
<div class="support-card__intro">
|
||||
<div class="support-card__emblem">
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
id="backup"
|
||||
>
|
||||
<section class="settings-group" id="backup">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>backup</mat-icon>
|
||||
@@ -12,7 +9,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="import-export-data">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
|
||||
<p>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
id="dashboard"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<section class="settings-group" id="dashboard" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
@@ -13,7 +9,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="show-dashboard">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
<p>
|
||||
@@ -29,7 +25,7 @@
|
||||
</div>
|
||||
|
||||
<div formGroupName="dashboardRails">
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="dashboard-hero">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_HERO' | translate }}</h4>
|
||||
<p>
|
||||
@@ -44,7 +40,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-continue-watching"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}</h4>
|
||||
<p>
|
||||
@@ -62,11 +61,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-live-favorites"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate
|
||||
}}</h4>
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
|
||||
@@ -82,11 +84,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate
|
||||
}}</h4>
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
|
||||
@@ -102,11 +102,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate
|
||||
}}</h4>
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
|
||||
@@ -122,7 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-sources">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}</h4>
|
||||
<p>
|
||||
@@ -140,11 +140,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate
|
||||
}}</h4>
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
|
||||
@@ -160,9 +160,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-trending"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}</h4>
|
||||
<h4>
|
||||
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
|
||||
@@ -178,7 +183,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="dashboard-rail-tmdb-recommendations"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-view-mode"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
||||
@@ -43,7 +46,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-offset"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
|
||||
@@ -69,7 +75,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="epg-sources"
|
||||
>
|
||||
<div class="setting-item__meta" data-test-id="epg-source-formats">
|
||||
<h4>{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}</p>
|
||||
@@ -178,7 +187,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="language">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
@@ -40,7 +40,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="theme"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
|
||||
@@ -74,7 +77,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div
|
||||
class="setting-item setting-item--full-control"
|
||||
data-setting-id="cover-size"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
|
||||
@@ -108,7 +114,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="cover-titles">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="cover-titles-label">
|
||||
{{ 'SETTINGS.COVER_TITLES' | translate }}
|
||||
@@ -127,7 +133,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="startup-behavior">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
|
||||
<p>
|
||||
@@ -160,7 +166,11 @@
|
||||
</div>
|
||||
|
||||
@if (supportsStartupWindowMode()) {
|
||||
<div class="setting-item" data-test-id="startup-window-mode-setting">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="startup-window-mode"
|
||||
data-test-id="startup-window-mode-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}</h4>
|
||||
<p>
|
||||
@@ -195,7 +205,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="show-captions">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
|
||||
<p>
|
||||
@@ -207,7 +217,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="strip-country-prefix">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
|
||||
<p>
|
||||
@@ -233,7 +243,7 @@
|
||||
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="portal-connectivity-guard">
|
||||
<div class="setting-item__meta">
|
||||
<h4 id="portal-connectivity-label">
|
||||
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="video-player">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="stream-format">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STREAM_FORMAT' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
|
||||
@@ -67,6 +67,7 @@
|
||||
@if (isWebPlayerSelected()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="web-player-shared-controls"
|
||||
data-test-id="web-player-shared-controls-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -91,7 +92,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-test-id="player-ambient-mode-setting">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="player-ambient-mode"
|
||||
data-test-id="player-ambient-mode-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }}
|
||||
@@ -109,7 +114,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-test-id="player-up-next-rail-setting">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="player-up-next-rail"
|
||||
data-test-id="player-up-next-rail-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }}
|
||||
@@ -131,6 +140,7 @@
|
||||
@if (supportsFullscreenChannelPanel()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="fullscreen-channel-panel"
|
||||
data-test-id="fullscreen-channel-panel-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -159,7 +169,11 @@
|
||||
<!-- Only the web players raise a playback diagnostic, so under MPV, VLC
|
||||
or Embedded MPV nothing would ever trigger a failover. -->
|
||||
@if (supportsVodMultiSource() && isWebPlayerSelected()) {
|
||||
<div class="setting-item" data-test-id="vod-auto-failover-setting">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="vod-auto-failover"
|
||||
data-test-id="vod-auto-failover-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }}
|
||||
@@ -181,6 +195,7 @@
|
||||
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="external-player-double-click"
|
||||
data-test-id="external-player-double-click-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -206,7 +221,7 @@
|
||||
}
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="show-external-playback-bar">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}</h4>
|
||||
<p>
|
||||
@@ -226,6 +241,7 @@
|
||||
@if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="embedded-mpv-frame-copy"
|
||||
data-test-id="embedded-mpv-frame-copy-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -262,6 +278,7 @@
|
||||
@if (form().value.player === 'embedded-mpv') {
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="embedded-mpv-extra-options"
|
||||
data-test-id="embedded-mpv-extra-options-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -332,6 +349,7 @@
|
||||
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="embedded-mpv-auto-reconnect"
|
||||
data-test-id="embedded-mpv-auto-reconnect-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -354,7 +372,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item" data-test-id="recording-folder-setting">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="recording-folder"
|
||||
data-test-id="recording-folder-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
@@ -391,7 +413,7 @@
|
||||
@if (
|
||||
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
|
||||
) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="mpv-player-path">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
@@ -418,6 +440,7 @@
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--external-player-arguments"
|
||||
data-setting-id="mpv-player-arguments"
|
||||
data-test-id="mpv-player-arguments-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -449,7 +472,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="mpv-reuse-instance">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
@@ -465,7 +488,7 @@
|
||||
@if (
|
||||
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
|
||||
) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="vlc-player-path">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
@@ -489,6 +512,7 @@
|
||||
|
||||
<div
|
||||
class="setting-item setting-item--external-player-arguments"
|
||||
data-setting-id="vlc-player-arguments"
|
||||
data-test-id="vlc-player-arguments-setting"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
@@ -520,7 +544,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="vlc-reuse-instance">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
id="remote-control"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<section class="settings-group" id="remote-control" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>settings_remote</mat-icon>
|
||||
@@ -13,7 +9,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="remote-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
@@ -27,7 +23,7 @@
|
||||
</div>
|
||||
|
||||
@if (form().value.remoteControl === true) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="remote-control-port">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
|
||||
<p>
|
||||
@@ -76,7 +72,7 @@
|
||||
</div>
|
||||
|
||||
@if (localIpAddresses().length > 0) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="remote-control-url">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
|
||||
<p>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
id="reset"
|
||||
>
|
||||
<section class="settings-group" id="reset">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
@@ -12,7 +9,10 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item danger-zone">
|
||||
<div
|
||||
class="setting-item danger-zone"
|
||||
data-setting-id="remove-all-playlists"
|
||||
>
|
||||
<div class="danger-zone__header">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
|
||||
|
||||
@@ -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(/<div\b[^>]*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(
|
||||
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
|
||||
)?.[1] ?? null,
|
||||
descriptionKey:
|
||||
meta.match(
|
||||
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
|
||||
)?.[1] ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function translationExists(key: string): boolean {
|
||||
return (
|
||||
typeof key
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === 'object'
|
||||
? (node as Record<string, unknown>)[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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
<section
|
||||
class="settings-group settings-search-results"
|
||||
data-test-id="settings-search-results"
|
||||
aria-labelledby="settings-search-results-title"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-text">
|
||||
<h3 id="settings-search-results-title">
|
||||
{{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
|
||||
</h3>
|
||||
<p aria-live="polite" data-test-id="settings-search-summary">
|
||||
{{
|
||||
'SETTINGS.SEARCH_RESULTS_COUNT'
|
||||
| translate: { count: results().length, query: query() }
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (results().length > 0) {
|
||||
<ul class="settings-search-results__list">
|
||||
@for (result of results(); track result.entry.id) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-search-result"
|
||||
[attr.data-test-id]="
|
||||
'settings-search-result-' + result.entry.id
|
||||
"
|
||||
(click)="selected.emit(result.entry)"
|
||||
>
|
||||
<mat-icon class="settings-search-result__icon">{{
|
||||
result.section.icon
|
||||
}}</mat-icon>
|
||||
<span class="settings-search-result__text">
|
||||
<span class="settings-search-result__label">{{
|
||||
result.label
|
||||
}}</span>
|
||||
@if (result.description) {
|
||||
<span
|
||||
class="settings-search-result__description"
|
||||
>{{ result.description }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
<span class="settings-search-result__section">{{
|
||||
result.sectionLabel
|
||||
}}</span>
|
||||
<mat-icon class="settings-search-result__chevron"
|
||||
>chevron_right</mat-icon
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<div class="settings-search-empty" data-test-id="settings-search-empty">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>{{ 'SETTINGS.SEARCH_EMPTY_HINT' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
readonly results = input.required<readonly SettingsSearchResult[]>();
|
||||
readonly selected = output<SettingsSearchEntry>();
|
||||
}
|
||||
@@ -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<string>;
|
||||
/** True once the form is hydrated, so form-dependent rows exist. */
|
||||
readonly ready: Signal<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLElement>);
|
||||
private readonly injector = inject(Injector);
|
||||
private highlightTimeoutId: ReturnType<typeof setTimeout> | 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<string, number> = {};
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
</header>
|
||||
|
||||
<div formGroupName="tmdb">
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="tmdb-enable">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_ENABLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="tmdb-api-key">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
|
||||
@@ -96,7 +96,10 @@
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
@if (m3uVodDetailsControl; as m3uVodDetails) {
|
||||
<div class="setting-item">
|
||||
<div
|
||||
class="setting-item"
|
||||
data-setting-id="tmdb-m3u-vod-details"
|
||||
>
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
|
||||
@@ -118,7 +121,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item" data-setting-id="tmdb-cache">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
|
||||
<p>
|
||||
|
||||
@@ -16,113 +16,137 @@
|
||||
<!-- Only the routed section renders. The parent keeps the shared
|
||||
form alive across section switches, so edits staged on one
|
||||
page survive a visit to another. -->
|
||||
@switch (activeSection()) {
|
||||
@case ('playback') {
|
||||
<app-settings-playback-section
|
||||
[form]="settingsForm"
|
||||
[players]="players()"
|
||||
[streamFormatEnum]="streamFormatEnum"
|
||||
[isDesktop]="isDesktop"
|
||||
[supportsManagedExternalPlayers]="
|
||||
supportsManagedExternalPlayers
|
||||
"
|
||||
[supportsExternalPlayerPathSettings]="
|
||||
supportsExternalPlayerPathSettings
|
||||
"
|
||||
[supportsVodMultiSource]="supportsVodMultiSource"
|
||||
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
|
||||
[frameCopyActive]="embeddedMpv.frameCopyActive()"
|
||||
(selectRecordingFolder)="selectRecordingFolder()"
|
||||
/>
|
||||
}
|
||||
@case ('epg') {
|
||||
<app-settings-epg-section
|
||||
[form]="settingsForm"
|
||||
[epgUrl]="form.epgUrl"
|
||||
[isClearingEpgData]="epg.isClearing()"
|
||||
[canBrowseFiles]="epg.supportsFilePicker"
|
||||
[epgViewModeOptions]="epgViewModeOptions"
|
||||
(refreshEpg)="epg.refresh($event)"
|
||||
(browseEpgSource)="epg.browse($event)"
|
||||
(removeEpgSource)="form.removeEpgSource($event)"
|
||||
(addEpgSource)="form.addEpgSource()"
|
||||
(refreshAllEpg)="epg.refreshAll()"
|
||||
(clearEpgData)="epg.clear()"
|
||||
(selectEpgViewMode)="form.selectEpgViewMode($event)"
|
||||
/>
|
||||
}
|
||||
@case ('dashboard') {
|
||||
<app-settings-dashboard-section [form]="settingsForm" />
|
||||
}
|
||||
@case ('remote-control') {
|
||||
<app-settings-remote-control-section
|
||||
[form]="settingsForm"
|
||||
[localIpAddresses]="remoteControl.localIpAddresses()"
|
||||
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
|
||||
(toggleQrCode)="remoteControl.toggleQrCode($event)"
|
||||
/>
|
||||
}
|
||||
@case ('tmdb') {
|
||||
<app-settings-tmdb-section [form]="settingsForm" />
|
||||
}
|
||||
@case ('backup') {
|
||||
<app-settings-backup-section
|
||||
[isPwa]="isPwa"
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[isExportingData]="backup.isExportingData()"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
/>
|
||||
}
|
||||
@case ('reset') {
|
||||
<app-settings-reset-section
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[canRemoveAllPlaylists]="playlistReset.canRemoveAll()"
|
||||
[playlistDeleteSummary]="playlistReset.deleteSummary()"
|
||||
[removeAllProgressLabel]="
|
||||
playlistReset.removeAllProgressLabel()
|
||||
"
|
||||
(removeAll)="removeAll()"
|
||||
/>
|
||||
}
|
||||
@case ('about') {
|
||||
<app-settings-about-section
|
||||
[isDesktop]="isDesktop"
|
||||
[form]="settingsForm"
|
||||
[updateChannelOptions]="updateChannelOptions"
|
||||
[version]="appUpdate.version()"
|
||||
[buildCommit]="buildCommit"
|
||||
[updateMessage]="appUpdate.updateMessage()"
|
||||
[appUpdateStatus]="appUpdate.status()"
|
||||
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
|
||||
(saveAndCheckForAppUpdate)="onSubmit()"
|
||||
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
|
||||
(installAppUpdate)="appUpdate.installAppUpdate()"
|
||||
(openManualAppUpdate)="appUpdate.openManualAppUpdate()"
|
||||
(openAppUpdateReleaseNotes)="
|
||||
appUpdate.openReleaseNotes()
|
||||
"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<app-settings-general-section
|
||||
[form]="settingsForm"
|
||||
[languageEnum]="languageEnum"
|
||||
[themeOptions]="themeOptions"
|
||||
[coverSizeOptions]="coverSizeOptions"
|
||||
[startupBehaviorOptions]="startupBehaviorOptions"
|
||||
[startupWindowModeOptions]="startupWindowModeOptions"
|
||||
[supportsPortalConnectivityGuard]="
|
||||
supportsPortalConnectivityGuard
|
||||
"
|
||||
[supportsStartupWindowMode]="supportsStartupWindowMode"
|
||||
(selectTheme)="form.selectTheme($event)"
|
||||
(selectCoverSize)="form.selectCoverSize($event)"
|
||||
/>
|
||||
<!-- An active header search term (`q`) replaces the section page
|
||||
with ranked results across every section. -->
|
||||
@if (search.isSearching()) {
|
||||
<app-settings-search-results
|
||||
[query]="search.query()"
|
||||
[results]="search.results()"
|
||||
(selected)="search.select($event)"
|
||||
/>
|
||||
} @else {
|
||||
@switch (activeSection()) {
|
||||
@case ('playback') {
|
||||
<app-settings-playback-section
|
||||
[form]="settingsForm"
|
||||
[players]="players()"
|
||||
[streamFormatEnum]="streamFormatEnum"
|
||||
[isDesktop]="isDesktop"
|
||||
[supportsManagedExternalPlayers]="
|
||||
supportsManagedExternalPlayers
|
||||
"
|
||||
[supportsExternalPlayerPathSettings]="
|
||||
supportsExternalPlayerPathSettings
|
||||
"
|
||||
[supportsVodMultiSource]="supportsVodMultiSource"
|
||||
[frameCopyAvailable]="
|
||||
embeddedMpv.frameCopyAvailable()
|
||||
"
|
||||
[frameCopyActive]="embeddedMpv.frameCopyActive()"
|
||||
(selectRecordingFolder)="selectRecordingFolder()"
|
||||
/>
|
||||
}
|
||||
@case ('epg') {
|
||||
<app-settings-epg-section
|
||||
[form]="settingsForm"
|
||||
[epgUrl]="form.epgUrl"
|
||||
[isClearingEpgData]="epg.isClearing()"
|
||||
[canBrowseFiles]="epg.supportsFilePicker"
|
||||
[epgViewModeOptions]="epgViewModeOptions"
|
||||
(refreshEpg)="epg.refresh($event)"
|
||||
(browseEpgSource)="epg.browse($event)"
|
||||
(removeEpgSource)="form.removeEpgSource($event)"
|
||||
(addEpgSource)="form.addEpgSource()"
|
||||
(refreshAllEpg)="epg.refreshAll()"
|
||||
(clearEpgData)="epg.clear()"
|
||||
(selectEpgViewMode)="form.selectEpgViewMode($event)"
|
||||
/>
|
||||
}
|
||||
@case ('dashboard') {
|
||||
<app-settings-dashboard-section [form]="settingsForm" />
|
||||
}
|
||||
@case ('remote-control') {
|
||||
<app-settings-remote-control-section
|
||||
[form]="settingsForm"
|
||||
[localIpAddresses]="
|
||||
remoteControl.localIpAddresses()
|
||||
"
|
||||
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
|
||||
(toggleQrCode)="remoteControl.toggleQrCode($event)"
|
||||
/>
|
||||
}
|
||||
@case ('tmdb') {
|
||||
<app-settings-tmdb-section [form]="settingsForm" />
|
||||
}
|
||||
@case ('backup') {
|
||||
<app-settings-backup-section
|
||||
[isPwa]="isPwa"
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[isExportingData]="backup.isExportingData()"
|
||||
(importData)="importData()"
|
||||
(exportData)="exportData()"
|
||||
/>
|
||||
}
|
||||
@case ('reset') {
|
||||
<app-settings-reset-section
|
||||
[isRemovingAllPlaylists]="
|
||||
playlistReset.isRemovingAllPlaylists()
|
||||
"
|
||||
[canRemoveAllPlaylists]="
|
||||
playlistReset.canRemoveAll()
|
||||
"
|
||||
[playlistDeleteSummary]="
|
||||
playlistReset.deleteSummary()
|
||||
"
|
||||
[removeAllProgressLabel]="
|
||||
playlistReset.removeAllProgressLabel()
|
||||
"
|
||||
(removeAll)="removeAll()"
|
||||
/>
|
||||
}
|
||||
@case ('about') {
|
||||
<app-settings-about-section
|
||||
[isDesktop]="isDesktop"
|
||||
[form]="settingsForm"
|
||||
[updateChannelOptions]="updateChannelOptions"
|
||||
[version]="appUpdate.version()"
|
||||
[buildCommit]="buildCommit"
|
||||
[updateMessage]="appUpdate.updateMessage()"
|
||||
[appUpdateStatus]="appUpdate.status()"
|
||||
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
|
||||
(saveAndCheckForAppUpdate)="onSubmit()"
|
||||
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
|
||||
(installAppUpdate)="appUpdate.installAppUpdate()"
|
||||
(openManualAppUpdate)="
|
||||
appUpdate.openManualAppUpdate()
|
||||
"
|
||||
(openAppUpdateReleaseNotes)="
|
||||
appUpdate.openReleaseNotes()
|
||||
"
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
<app-settings-general-section
|
||||
[form]="settingsForm"
|
||||
[languageEnum]="languageEnum"
|
||||
[themeOptions]="themeOptions"
|
||||
[coverSizeOptions]="coverSizeOptions"
|
||||
[startupBehaviorOptions]="startupBehaviorOptions"
|
||||
[startupWindowModeOptions]="
|
||||
startupWindowModeOptions
|
||||
"
|
||||
[supportsPortalConnectivityGuard]="
|
||||
supportsPortalConnectivityGuard
|
||||
"
|
||||
[supportsStartupWindowMode]="
|
||||
supportsStartupWindowMode
|
||||
"
|
||||
(selectTheme)="form.selectTheme($event)"
|
||||
(selectCoverSize)="form.selectCoverSize($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SettingsComponent>;
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ParamMap>(
|
||||
convertToParamMap({ section: 'general' })
|
||||
);
|
||||
private readonly queryParams = new BehaviorSubject<ParamMap>(
|
||||
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);
|
||||
|
||||
|
||||
@@ -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": "اختصارات لوحة المفاتيح",
|
||||
|
||||
@@ -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": "اختصارات الكلافيي",
|
||||
|
||||
@@ -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": "Спалучэнні клавіш",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Συντομεύσεις πληκτρολογίου",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "キーボードショートカット",
|
||||
|
||||
@@ -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": "키보드 단축키",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сочетания клавиш",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "键盘快捷键",
|
||||
|
||||
@@ -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": "鍵盤快捷鍵",
|
||||
|
||||
+14
-1
@@ -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/<slug>/`. 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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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/' },
|
||||
|
||||
@@ -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 = [
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<div class="hidden items-center gap-8 md:flex">
|
||||
<div class="hidden items-center gap-5 lg:flex xl:gap-8">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors duration-200 hover:text-accent-400"
|
||||
aria-current={Astro.url.pathname === link.href ? 'page' : undefined}
|
||||
class="text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors duration-200 hover:text-accent-400 aria-[current=page]:text-accent-400"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
@@ -42,7 +44,7 @@ const navLinks = [
|
||||
</div>
|
||||
|
||||
<!-- Right side actions -->
|
||||
<div class="hidden items-center gap-4 md:flex">
|
||||
<div class="hidden items-center gap-4 lg:flex">
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator"
|
||||
target="_blank"
|
||||
@@ -65,8 +67,10 @@ const navLinks = [
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
class="inline-flex items-center justify-center rounded-lg p-2 text-surface-400 transition-colors hover:text-surface-100 md:hidden"
|
||||
class="inline-flex items-center justify-center rounded-lg p-2 text-surface-400 transition-colors hover:text-surface-100 lg:hidden"
|
||||
aria-label="Toggle menu"
|
||||
aria-controls="mobile-menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg id="menu-icon-open" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -80,14 +84,15 @@ const navLinks = [
|
||||
<!-- Mobile menu -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="hidden border-t border-surface-700/60 bg-surface-950/98 backdrop-blur-2xl md:hidden"
|
||||
class="hidden border-t border-surface-700/60 bg-surface-950/98 backdrop-blur-2xl lg:hidden"
|
||||
>
|
||||
<div class="space-y-1 px-6 py-5">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="block rounded-lg px-3 py-2.5 text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors hover:bg-surface-800/50 hover:text-accent-400"
|
||||
aria-current={Astro.url.pathname === link.href ? 'page' : undefined}
|
||||
class="block rounded-lg px-3 py-2.5 text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors hover:bg-surface-800/50 hover:text-accent-400 aria-[current=page]:text-accent-400"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
@@ -125,6 +130,7 @@ const navLinks = [
|
||||
|
||||
btn?.addEventListener('click', () => {
|
||||
const isHidden = menu?.classList.toggle('hidden');
|
||||
btn?.setAttribute('aria-expanded', String(!isHidden));
|
||||
iconOpen?.classList.toggle('hidden', !isHidden);
|
||||
iconClose?.classList.toggle('hidden', isHidden);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Switch Channels and Episodes Without Leaving Fullscreen
|
||||
description: Open IPTVnator's fullscreen side panel with C or the left edge of the video. Search live channels, choose a season, and switch episodes while keeping the player fullscreen.
|
||||
pubDate: 2026-09-27
|
||||
author: 4gray
|
||||
tags:
|
||||
- guide
|
||||
- playback
|
||||
draft: true
|
||||
faq:
|
||||
- q: Why does C do nothing?
|
||||
a: Start a live channel or series episode, enter player fullscreen, and check Channel and episode list in fullscreen under Settings → Playback. For web players, enable Unified controls for web players too. The shortcut does not run while you are typing in a text field.
|
||||
- q: Can I use the panel with external VLC or MPV?
|
||||
a: No. External players have their own windows and controls. The panel works with the built-in web players using unified controls and with experimental frame-copy Embedded MPV, not native-view Embedded MPV.
|
||||
- q: How do I close the list without leaving fullscreen?
|
||||
a: Use its close button, click the video outside it, or press Escape while the panel is open. Once the panel is closed, Escape can leave fullscreen normally.
|
||||
- q: Does pressing C again close the panel?
|
||||
a: No. C opens the panel and focuses its search field when one is present. Typing another C then searches for that character. Use Escape or the close button to dismiss the panel.
|
||||
- q: Does the episode panel work for both Xtream and Stalker?
|
||||
a: Yes. During series playback it shows season tabs and episodes for Xtream and Stalker sources, with the information available for that show. A standalone movie has no episode panel.
|
||||
---
|
||||
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
|
||||
Fullscreen is useful until changing channels makes you leave it. IPTVnator 0.24 puts a
|
||||
channel or episode list over the side of the video, so you can choose what comes next and
|
||||
keep watching at the same size.
|
||||
|
||||
For live TV, the panel provides channel search. For a series, it provides seasons and
|
||||
episodes with artwork and watch progress. The content follows what you are playing.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
## Check the playback settings
|
||||
|
||||
Open **Settings → Playback** and enable **Channel and episode list in fullscreen**.
|
||||
If you use HTML5, Video.js or ArtPlayer, keep **Unified controls for web players** enabled
|
||||
as well. These are the shared controls introduced as the default in 0.23.
|
||||
|
||||
The panel is available with those web players and the experimental **frame-copy** Embedded
|
||||
MPV engine. Native-view Embedded MPV and external player windows cannot show this panel.
|
||||
|
||||
Enter fullscreen using the player's fullscreen control. Maximizing the desktop window
|
||||
gives the app more room, but is not the same as putting the video into player fullscreen.
|
||||
|
||||
## Browse live channels
|
||||
|
||||
1. Start a live channel, then enter player fullscreen.
|
||||
2. Press **C**, or rest the pointer on the left edge of the video. Clicking the edge opens
|
||||
the panel too; on a touchscreen, tap it.
|
||||
3. Use the search field to narrow the channel list.
|
||||
4. Select a channel. With the shared controls, playback switches while fullscreen remains
|
||||
active.
|
||||
5. Press **Escape** or use the panel's close button when you want the whole picture again.
|
||||
|
||||
The channel list follows the source or collection you are watching. M3U offers views for
|
||||
all channels, groups, favorites and recent channels; portal lists follow their relevant
|
||||
channel context. Searching here chooses from that list rather than searching every
|
||||
playlist in your library.
|
||||
|
||||
For M3U, **Page Up / Page Down** also let you step through channels without opening the
|
||||
panel. Those keys have a different job inside the separate programme Guide, where they
|
||||
move between days.
|
||||
|
||||
## Choose another episode
|
||||
|
||||
Start an Xtream or Stalker series episode and open the panel the same way. Instead of a
|
||||
channel search, you get season navigation and an episode list. The list shows available
|
||||
stills, descriptions, runtimes and watch progress, helping you find where you left off.
|
||||
|
||||
Choose a season, then an episode. The selected episode starts in the same fullscreen
|
||||
view. If your source does not provide artwork or a description, the episode can still be
|
||||
selected; missing metadata does not turn it into a different playback mode.
|
||||
|
||||
The panel is for series playback. A movie does not gain an episode list, and the feature
|
||||
does not change the [phone remote's live-channel controls](/iptvnator/blog/remote-control-guide/).
|
||||
|
||||
## Keep fullscreen when switching
|
||||
|
||||
The shared controls keep fullscreen on the surrounding player area as the stream changes.
|
||||
If you opt back into a web player's original controls, switching can leave fullscreen
|
||||
because that player replaces the video surface. Enable unified controls if this is the
|
||||
behavior you want to avoid.
|
||||
|
||||
If fullscreen still ends unexpectedly, note the selected player, whether unified controls
|
||||
are enabled, and whether you changed a channel, an episode, or the source of a movie. Also
|
||||
note whether an error appeared: a playback failure may need to reveal recovery actions.
|
||||
|
||||
## If the panel seems hard to reach
|
||||
|
||||
Use **C** first to distinguish a settings problem from a pointer interaction problem.
|
||||
Click the video before trying the shortcut if focus is still in a text field. Once the
|
||||
channel search has focus, letter keys are ordinary search input; pressing C again does
|
||||
not toggle the panel closed.
|
||||
|
||||
Use Escape to close it, then try the left edge again. On touch, tap the edge above the
|
||||
control bar rather than a playback button.
|
||||
|
||||
## Related
|
||||
|
||||
- [Control live TV from your phone](/iptvnator/blog/remote-control-guide/)
|
||||
- [Compare playback engines](/iptvnator/compare/playback-engines/)
|
||||
- [Fullscreen changes in 0.24](/iptvnator/blog/v0-24-release-notes/)
|
||||
- [Download IPTVnator](/iptvnator/download/)
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: "Keep Your Library Tidy: Sources, Favorites and Watched Titles"
|
||||
description: Review source availability, return from a favorite channel to its playlist, mark titles watched and simplify catalog views in IPTVnator without confusing hidden items with missing data.
|
||||
pubDate: 2026-09-27
|
||||
author: 4gray
|
||||
tags:
|
||||
- guide
|
||||
draft: true
|
||||
faq:
|
||||
- q: Does an unavailable source mean my channels were deleted?
|
||||
a: No. Availability describes a connection check. A timeout, a temporary outage or an unverified account is different from deleting a saved source or losing its channels.
|
||||
- q: Does source cleanup delete entries automatically?
|
||||
a: No. The desktop cleanup dialog checks sources and lets you review the selection before deleting. Confirmed expired or disabled accounts can be preselected; uncertain results need review.
|
||||
- q: What happens when I delete a source?
|
||||
a: Its saved favorites, history and playback progress are removed with it. Downloaded files are kept. Export a playlist backup first if you want a restorable copy of supported source state.
|
||||
- q: Can I mark a movie watched without playing it to the end?
|
||||
a: Yes. In 0.24, Xtream and Stalker movie detail pages offer manual watched and unwatched actions, including details opened from Favorites or Recently Viewed.
|
||||
- q: Does hiding titles under covers make names inaccessible?
|
||||
a: No. Titles appear when you hover over a cover or focus it with the keyboard. Items without artwork keep their titles visible, and live channels and search results keep their labels.
|
||||
---
|
||||
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
|
||||
A library with several sources can become difficult to read even when everything works.
|
||||
You might see an old account beside an active one, a favorite without remembering its
|
||||
source, or a film you finished elsewhere still waiting to resume.
|
||||
|
||||
IPTVnator 0.23 and 0.24 add ways to handle those everyday details. Start with the view you
|
||||
are in, then review the saved source itself only when something actually needs changing.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
|
||||
## First check whether the list is hidden
|
||||
|
||||
If a live channel is playing but its list has disappeared, look for **Show channels list**
|
||||
or the list toggle in the workspace header. A collapsed panel is a layout choice, not
|
||||
evidence that the playlist lost its channels.
|
||||
|
||||
Portal Live TV panels can collapse in steps: hide the categories while keeping the channel
|
||||
list, or hide both for more video space. With categories hidden, the list header offers a
|
||||
category selector, so you can still change the group of channels you are browsing.
|
||||
|
||||
In Favorites and Recently Viewed, also check whether the scope is **This playlist** or
|
||||
**All playlists**. A different scope can explain why an item is visible in one view but
|
||||
not another.
|
||||
|
||||
## Find the source behind a favorite
|
||||
|
||||
While watching a live channel from Favorites or Recently Viewed, look for its playlist
|
||||
name in the programme panel. Click that source chip, or use **Open in…** from the channel's
|
||||
right-click menu, to open the channel inside its original playlist.
|
||||
|
||||
In 0.24 this works for M3U, Xtream and Stalker live channels. It is useful when a favorite
|
||||
plays correctly and you want to browse neighboring channels, or when the same name exists
|
||||
in several sources and you need to know which copy you selected.
|
||||
|
||||
## Read source availability as a check result
|
||||
|
||||
The desktop source list shows availability information for Xtream accounts, Stalker
|
||||
portals and M3U links. A result can tell you that an account is inactive or that a source
|
||||
could not be verified. Those are different situations.
|
||||
|
||||
A temporary connection failure is a reason to inspect or retry, not proof that the source
|
||||
should be removed. If several sources fail together, consider a shared network or VPN
|
||||
problem before deciding that every account has expired.
|
||||
|
||||
## Review old sources together
|
||||
|
||||
1. Export a playlist backup from **Settings → Backup** if you want a copy before deleting.
|
||||
2. On the desktop sources page, open **Clean up inactive sources…**.
|
||||
3. Let the checks run. The dialog checks network sources across the library, even when the
|
||||
page behind it has a filter applied.
|
||||
4. Review both confirmed inactive accounts and uncertain results. Deselect anything you
|
||||
want to keep.
|
||||
5. Use **Delete selected** only for the sources you intend to remove, then read the
|
||||
per-source results.
|
||||
|
||||
Busy or playing sources can be skipped. A skipped row is not a successful deletion, and
|
||||
an uncertain result is not a confirmed expired account.
|
||||
|
||||
Deleting a source also removes its saved favorites, history and playback progress.
|
||||
Downloaded files are kept. Hiding a category or collapsing a panel is a better fit when
|
||||
your goal is simply to see less in the current view.
|
||||
|
||||
## Keep watch state in step with what you finished
|
||||
|
||||
For Xtream and Stalker movies, open the detail page and use **Mark as Watched** when you
|
||||
have already finished the film. Use **Mark as Unwatched** to reverse the choice. The
|
||||
catalog updates its watched indicator, and a watched movie offers Play rather than a
|
||||
resume near the end.
|
||||
|
||||
Series already have controls for marking a season or the whole series watched. Use those
|
||||
when you are catching the library up with your viewing, rather than opening every episode
|
||||
just to seek to its end.
|
||||
|
||||
## Fit more covers on screen
|
||||
|
||||
Under **Settings → General**, turn off **Show titles under covers** for a denser movie
|
||||
and series grid. Titles appear on hover or keyboard focus, and missing artwork keeps a
|
||||
visible title. Live channels and search results retain their labels.
|
||||
|
||||
This changes presentation, not the names stored in the library. Turn it back on whenever
|
||||
permanent labels are more useful than an extra row of posters.
|
||||
|
||||
## Related
|
||||
|
||||
- [Movie and series metadata with TMDB](/iptvnator/blog/tmdb-metadata-guide/)
|
||||
- [Alternative movie sources](/iptvnator/blog/alternative-sources-guide/)
|
||||
- [Library changes in 0.24](/iptvnator/blog/v0-24-release-notes/)
|
||||
- [Download IPTVnator](/iptvnator/download/)
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Browse the TV Guide Without Leaving Your Channel
|
||||
description: Keep watching while you compare channel schedules in IPTVnator's M3U programme guide. Open the grid, filter by group or favorites, and switch channels without closing it.
|
||||
pubDate: 2026-09-27
|
||||
author: 4gray
|
||||
tags:
|
||||
- guide
|
||||
- epg
|
||||
- m3u
|
||||
draft: true
|
||||
faq:
|
||||
- q: Is this guide available in the browser version?
|
||||
a: The multi-channel M3U Guide described here is a desktop feature in 0.24. The browser version and portal programme views do not offer this same layout.
|
||||
- q: Does the small player open a second stream?
|
||||
a: No. Opening the guide moves the existing inline player into a smaller area above the grid. Selecting another channel changes what that player is playing.
|
||||
- q: Why does a channel appear without a programme?
|
||||
a: The channel list comes from your playlist. A schedule appears only when imported EPG data can be matched to that channel for the selected time. Check the EPG source and channel mapping, or enable Only with EPG to hide empty rows.
|
||||
- q: Can I open the guide while fullscreen?
|
||||
a: No. Leave fullscreen before opening the Guide. For changing channels while staying fullscreen, use the channel panel opened with C instead.
|
||||
- q: Will external MPV or VLC appear above the grid?
|
||||
a: No. External players keep their own window. The guide shows a compact now-playing strip, without an embedded video preview.
|
||||
---
|
||||
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
|
||||
You are watching a channel, but want to see what else is on. Opening each channel in turn
|
||||
makes that a slow search. IPTVnator 0.24 adds a different way to browse: a programme grid
|
||||
with your M3U channels in playlist order and the current video still playing above it.
|
||||
|
||||
This guide covers the desktop M3U Guide. Have an M3U playlist and its XMLTV data loaded
|
||||
first; the [playlist and EPG setup guide](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
|
||||
covers that preparation.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
## Open the guide while watching
|
||||
|
||||
1. Open an M3U playlist and start a live TV channel.
|
||||
2. Leave fullscreen if it is active, and close any open settings dialog.
|
||||
3. Click **Guide** in the programme panel or use the Guide action in the workspace header.
|
||||
You can also press **G** while on the player page, outside a text field or menu.
|
||||
4. Browse the grid. The inline player stays above it alongside information about the
|
||||
channel you are watching.
|
||||
|
||||
Opening Guide does not restart the current channel. The same player changes size to make
|
||||
room for the schedules. Collapse the preview strip when you want more space for the grid,
|
||||
then expand it when you want to see the picture again.
|
||||
|
||||
## Choose the channels you want to compare
|
||||
|
||||
The rows come from **your playlist**, rather than every channel present in an XMLTV file.
|
||||
This keeps their order familiar and avoids filling the grid with channels you cannot play.
|
||||
|
||||
Choose all channels, a group, or favorites. Opening the guide from the favorites view
|
||||
starts with favorites; opening it while browsing a group keeps that group in view.
|
||||
For a large playlist, a small group makes it easier to compare programmes side by side.
|
||||
|
||||
Use **Only with EPG** to hide channels without guide coverage. Switch it off again if you
|
||||
are looking for a channel that plays correctly but has no schedule. Hiding a row here does
|
||||
not remove the channel from the playlist.
|
||||
|
||||
Comfortable rows leave more room between channels. Compact rows fit more of the list on
|
||||
screen. The guide remembers the density and Only with EPG choices for the next visit.
|
||||
|
||||
## Switch now, or inspect a programme first
|
||||
|
||||
| Action | Result |
|
||||
| --- | --- |
|
||||
| Click a channel row or its currently airing programme | Switch playback and keep browsing the guide |
|
||||
| Double-click a channel | Switch and close the guide |
|
||||
| Open another programme card | Read its programme details |
|
||||
| Close the guide | Return to the usual player layout |
|
||||
|
||||
A future programme is a schedule entry, not a playable stream waiting to start. Likewise,
|
||||
seeing a past programme in this grid does not establish that your source offers an archive.
|
||||
|
||||
## Navigate from the keyboard
|
||||
|
||||
With focus in the guide grid, the arrow keys move between channels and programmes.
|
||||
**Enter** switches to the selected channel and closes the guide; **I** opens details,
|
||||
**N** returns to now, and **Escape** closes the guide. **Page Up / Page Down** change the day.
|
||||
|
||||
These are guide controls. Outside the guide, Page Up / Page Down can switch M3U channels,
|
||||
so close the grid before using those keys to zap through the playlist.
|
||||
|
||||
## If the grid looks empty
|
||||
|
||||
First check the selected group and Only with EPG filter. Then look at whether the same
|
||||
channel has programme data in the ordinary player view. If both are empty, verify that the
|
||||
XMLTV source loaded and contains the channel and date you are browsing.
|
||||
|
||||
If the schedule belongs to another channel or is several hours out, use the
|
||||
[EPG mapping and time-offset guide](/iptvnator/blog/epg-wrong-program-fix/).
|
||||
The multi-channel grid uses those mappings and the display offset too; importing the
|
||||
same file again is not a substitute for correcting a mismatched channel.
|
||||
|
||||
## Related
|
||||
|
||||
- [Set up an M3U playlist and XMLTV guide](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
|
||||
- [Fix the wrong programme or missing EPG](/iptvnator/blog/epg-wrong-program-fix/)
|
||||
- [What changed in 0.24](/iptvnator/blog/v0-24-release-notes/)
|
||||
- [Download IPTVnator for desktop](/iptvnator/download/)
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: "Subtitles, Quality and Picture-in-Picture: Get More from the Player"
|
||||
description: Use IPTVnator's unified player controls to choose stream quality, load subtitles, adjust their timing and keep video visible in picture-in-picture. Learn why some options depend on the stream or player.
|
||||
pubDate: 2026-09-27
|
||||
author: 4gray
|
||||
tags:
|
||||
- guide
|
||||
- playback
|
||||
draft: true
|
||||
faq:
|
||||
- q: Why is there no quality menu?
|
||||
a: The menu appears when the stream exposes more than one video quality. A single video file or a channel with only one rendition has no alternatives for the player to select.
|
||||
- q: Which subtitle files can I load?
|
||||
a: The shared web-player controls accept SRT and WebVTT files. Embedded MPV also supports formats such as ASS, whose embedded styling may take precedence over the text-style controls.
|
||||
- q: Why can I adjust one subtitle track's timing but not another's?
|
||||
a: Timing support depends on the engine and track. In the shared web-player path, delay adjustment is available for the selected external subtitle file. Embedded MPV can apply delay to its subtitle tracks more broadly.
|
||||
- q: Does Embedded MPV support the picture-in-picture button?
|
||||
a: No. The shared picture-in-picture action belongs to supported web-player video playback. It is not available for Embedded MPV or external MPV and VLC windows.
|
||||
- q: Will my quality and subtitle choices be remembered?
|
||||
a: Subtitle text size and colour are saved. Manual quality selection and subtitle timing are session choices; do not assume they will carry over to the next stream. Auto is the default quality mode.
|
||||
---
|
||||
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
|
||||
Subtitles arrive a little late. A stream offers several qualities, but the connection is
|
||||
struggling with the highest one. You want to keep an eye on a programme while using another
|
||||
window. These are all tasks you can handle from the player controls.
|
||||
|
||||
Since 0.23, HTML5, Video.js and ArtPlayer use IPTVnator's unified controls by default.
|
||||
The buttons look consistent across those players, while the available options still
|
||||
depend on what the current stream and playback engine support.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
|
||||
## Start with unified controls
|
||||
|
||||
Open **Settings → Playback** and check **Unified controls for web players**. With it
|
||||
enabled, the three built-in web players share their control bar. Turning it off restores
|
||||
each player's own controls, so the steps below may no longer match what you see.
|
||||
|
||||
Move the pointer over a playing video to reveal the controls. On touch, tap the video to
|
||||
show or hide them. A missing button often means that the current stream cannot offer that
|
||||
action, rather than that a setting has been lost.
|
||||
|
||||
## Choose a quality the stream actually provides
|
||||
|
||||
Open **Quality** when the menu is available. **Auto** lets the engine select a rendition;
|
||||
a label such as **720p** or **1080p** pins one of the variants supplied by the stream.
|
||||
|
||||
If playback repeatedly stalls, try a lower available quality and watch whether it becomes
|
||||
smoother. That is a useful comparison, not a diagnosis by itself: interruptions can also
|
||||
come from the source or decoding path.
|
||||
|
||||
A provider may list separate “HD” and “SD” channels rather than offering both qualities
|
||||
inside one stream. In that case, change the channel or source. The quality menu cannot
|
||||
create a rendition that was never supplied, and choosing 1080p cannot turn a lower-resolution
|
||||
source into native 1080p video.
|
||||
|
||||
Quality selection is a session choice. Check the menu again after starting another stream
|
||||
instead of assuming the previous selection still applies.
|
||||
|
||||
## Load a subtitle file
|
||||
|
||||
1. Start the movie or episode you want to watch.
|
||||
2. Open **Subtitles** in the player controls.
|
||||
3. Choose **Load subtitle file…** and select an `.srt` or `.vtt` file for the web players.
|
||||
4. Select the loaded track if needed, then check a line of dialogue against the picture.
|
||||
|
||||
Embedded MPV accepts additional subtitle formats, including `.ass`. Use the player that
|
||||
supports your file rather than simply changing its filename extension.
|
||||
|
||||
If the stream already includes subtitle tracks, you can choose one directly from the
|
||||
same menu. **Off** hides subtitles without removing the movie or changing the audio track.
|
||||
|
||||
## Adjust timing and readability
|
||||
|
||||
For a selected external subtitle in the web players, use **Show subtitles earlier** or
|
||||
**Show subtitles later**. Each step moves the timing by half a second. Reset the delay
|
||||
when you want to return to the file's original timing.
|
||||
|
||||
Test a few lines before making another adjustment. If the error grows as the film plays,
|
||||
a constant delay may not be enough: the subtitle file may belong to a different edit or
|
||||
timing version of the film.
|
||||
|
||||
The subtitle menu also offers text size and colour. Those appearance choices are saved
|
||||
for later sessions. Timing is separate and should be checked for each file. Styled ASS
|
||||
subtitles in MPV can retain their own embedded appearance.
|
||||
|
||||
## Keep the video visible with picture-in-picture
|
||||
|
||||
On supported web-player playback, choose **Enter picture-in-picture** to put the video
|
||||
in a small floating window. Use the PiP window's return control or the player's exit
|
||||
action to bring it back.
|
||||
|
||||
The button appears only when the playback environment supports it. Embedded MPV does not
|
||||
offer this shared PiP action. The small preview above the M3U programme grid is a different
|
||||
layout: it remains inside the app and does not require PiP support.
|
||||
|
||||
## Related
|
||||
|
||||
- [Choose a playback engine](/iptvnator/compare/playback-engines/)
|
||||
- [Why some streams need an external player](/iptvnator/blog/why-external-players-help/)
|
||||
- [Player-control changes in 0.23](/iptvnator/blog/v0-23-release-notes/)
|
||||
- [Download IPTVnator](/iptvnator/download/)
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: Back Up Your Playlists and Move to a New Computer
|
||||
description: Export and restore an IPTVnator playlist backup, understand which favorites and playback state it carries, and check what needs to be moved separately when changing computers.
|
||||
pubDate: 2026-09-27
|
||||
author: 4gray
|
||||
tags:
|
||||
- guide
|
||||
- troubleshooting
|
||||
draft: true
|
||||
faq:
|
||||
- q: Is a playlist backup a complete copy of the app?
|
||||
a: No. It contains playlist definitions, supported playlist-specific user state and global EPG source addresses. It does not include the full settings profile, cached catalog and EPG data, or downloaded media.
|
||||
- q: Are favorites included?
|
||||
a: Yes. M3U, Xtream and Stalker entries include their supported favorites and recent-item state. Xtream also carries playback positions and hidden categories; Stalker playback positions are not included in the version 1 backup format.
|
||||
- q: Does importing the same source create a duplicate?
|
||||
a: A source the importer recognizes is merged with the existing entry. Its saved playlist state is restored from the backup, so this is not simply an additive merge of every old and new favorite. Unmatched sources are created as new entries.
|
||||
- q: Does the backup include my provider password?
|
||||
a: The current export includes connection secrets so sources can be restored. Playlist and EPG URLs can also contain credentials. Treat the JSON file as private account data and do not attach it to a public issue.
|
||||
- q: Can I import an old raw JSON playlist dump?
|
||||
a: The current importer expects a versioned IPTVnator playlist backup, not the older raw array of playlists. Use a backup exported through the current Settings → Backup workflow.
|
||||
---
|
||||
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
|
||||
Adding a source takes a moment. Rebuilding favorites, hidden categories and playback
|
||||
positions takes longer. A playlist backup keeps the supported parts of that library
|
||||
together so you can restore them after a problem or move them to another computer.
|
||||
|
||||
This guide describes the versioned backup available in IPTVnator 0.24. Its scope varies
|
||||
by source type, so check the table below before relying on it as your only copy of data.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
|
||||
## Export before changing anything
|
||||
|
||||
1. On the computer with the library you want to keep, open **Settings → Backup**.
|
||||
2. Under **Import/export playlist backups**, choose **Export**.
|
||||
3. Save the JSON file. The suggested name includes the export date, for example
|
||||
`iptvnator-playlist-backup-2026-09-27.json`.
|
||||
4. Keep a copy outside the app's own data folder. If you are moving computers, transfer
|
||||
it through a location you control.
|
||||
|
||||
Export the receiving computer's library too if it already has sources and favorites you
|
||||
care about. Restoring a snapshot can replace saved state for a matching source; preserving
|
||||
both backups gives you a record of each library before the import.
|
||||
|
||||
## What travels in the file
|
||||
|
||||
| Source or data | Included | Separate or rebuilt later |
|
||||
| --- | --- | --- |
|
||||
| M3U | Playlist text, source information, favorites, recent items and hidden groups | Media files referenced by local paths |
|
||||
| Xtream | Connection details, favorites, recent items, hidden categories and playback positions | The downloaded catalog cache and offline media |
|
||||
| Stalker | Connection and device details, favorites and recent-item snapshots | Active login sessions and playback positions in backup format v1 |
|
||||
| Global EPG | Configured source addresses | Imported programme data and local XMLTV files |
|
||||
| App preferences | The EPG addresses above | Other settings, including player and appearance choices |
|
||||
|
||||
An M3U backup includes the playlist text even if the original playlist came from a local
|
||||
file. It does not include the video files those entries may point to. Paths that worked on
|
||||
one computer may need updating on another.
|
||||
|
||||
The export includes connection credentials. Store it as private data, not as an attachment
|
||||
to a public support thread. If a report needs an example playlist, prepare a separate
|
||||
redacted example rather than sharing your backup.
|
||||
|
||||
## Restore on the destination
|
||||
|
||||
1. Install a current supported version of IPTVnator on the destination computer.
|
||||
2. Open **Settings → Backup**, choose **Import**, and select the exported file.
|
||||
3. Read the summary: it reports sources that were imported, merged, skipped or failed.
|
||||
4. Open each restored source and let any required catalog initialization finish.
|
||||
5. Check a few favorites, recent items and, for Xtream, a saved playback position.
|
||||
|
||||
Xtream restores may need the catalog to load before saved items can be matched to their
|
||||
content. An imported source appearing in the list does not mean every catalog operation
|
||||
has already completed.
|
||||
|
||||
Stalker connects again using its restored source definition rather than transferring a
|
||||
running session. The provider's device and connection rules still apply on the destination.
|
||||
|
||||
## Understand what “merged” means
|
||||
|
||||
The importer recognizes sources from their connection identity. An M3U URL, an Xtream
|
||||
server and username, or a Stalker portal and MAC address can identify an existing source.
|
||||
For an M3U without a URL, the playlist content provides the match.
|
||||
|
||||
When an entry matches, the existing source is updated and its backed-up user state is
|
||||
applied. This is not a promise to combine every favorite from two different points in time.
|
||||
For example, importing an older snapshot can replace changes made after that snapshot.
|
||||
|
||||
## Move the remaining pieces deliberately
|
||||
|
||||
Downloaded movies, episodes and recordings are not inside the JSON file, nor does importing
|
||||
a playlist backup recreate the Downloads library. Keep the media separately and do not
|
||||
assume that copying files alone registers them in the new app's download manager.
|
||||
|
||||
Move local XMLTV files separately and select their new locations. Reapply other preferences,
|
||||
such as the selected player and update channel, on the destination.
|
||||
|
||||
A backup is also separate from recovery of sources missing after an older upgrade. If data
|
||||
has unexpectedly disappeared, first record the versions and what happened; repeatedly
|
||||
importing snapshots can make it harder to tell which state you are examining.
|
||||
|
||||
## Related
|
||||
|
||||
- [Add an M3U playlist and EPG](/iptvnator/blog/m3u-playlist-epg-setup-guide/)
|
||||
- [Connect an Xtream account](/iptvnator/blog/xtream-codes-setup-guide/)
|
||||
- [Connect a Stalker portal](/iptvnator/blog/stalker-portal-setup-guide/)
|
||||
- [Download IPTVnator](/iptvnator/download/)
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Stable or Nightly? How IPTVnator Updates Work
|
||||
description: Choose an update channel in the desktop app, understand what happens when you switch back to Stable, and find the build details that make a nightly bug report useful.
|
||||
pubDate: 2026-09-27
|
||||
author: 4gray
|
||||
tags:
|
||||
- guide
|
||||
draft: true
|
||||
faq:
|
||||
- q: Which update channel should I use?
|
||||
a: Stable is the default and suits everyday viewing. Nightly is for trying changes before the next stable release and helping test them. Nightly builds can introduce regressions.
|
||||
- q: Does choosing Nightly immediately change the installed app?
|
||||
a: Changing the selection does not install anything by itself. Save the setting, let the app check the chosen channel, and follow the update or manual-install action it offers.
|
||||
- q: Why am I still on a nightly after choosing Stable?
|
||||
a: The updater only moves forward. It keeps the installed nightly until a stable release with a newer version is available. Selecting Stable changes which channel is checked; it does not downgrade the app.
|
||||
- q: Does every Linux package update inside IPTVnator?
|
||||
a: No. The update flow depends on the installation format. Linux installations without AppImage use a manual-install fallback in the app. Package-manager installations should follow their package manager's update process.
|
||||
- q: What details should I include when reporting a nightly problem?
|
||||
a: Include the full version and build commit from About, your operating system, installation format, player and source type, and the shortest steps that reproduce the problem. Do not post account credentials or full playlist URLs.
|
||||
---
|
||||
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
|
||||
Stable releases collect changes into a version intended for everyday use. Nightly builds
|
||||
let you try work merged since then. Since IPTVnator 0.24, the desktop app lets you choose
|
||||
between those channels in **Settings → About**.
|
||||
|
||||
The choice controls where the app looks for updates. It does not create a second library
|
||||
or an isolated testing profile: a nightly uses the data of the app you update.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
## Choose the channel that matches your use
|
||||
|
||||
| Channel | A good fit when… | What to expect |
|
||||
| --- | --- | --- |
|
||||
| Stable | You mainly want to watch and update at release time | Tagged releases; this is the default |
|
||||
| Nightly | You want to try a recent fix or help test development | Builds from merged development changes, including possible regressions |
|
||||
|
||||
Nightly can be useful when an issue you reported has been fixed but the next stable release
|
||||
has not arrived. Include the build you tested when replying to that issue: two people using
|
||||
“nightly” may be running different code.
|
||||
|
||||
## Switch to Nightly
|
||||
|
||||
1. Export a playlist backup from **Settings → Backup** and keep the file somewhere you can
|
||||
find it. A playlist backup does not include downloaded media or every app preference.
|
||||
2. Open **Settings → About** in the desktop app.
|
||||
3. Change **Update channel** from **Stable** to **Nightly**.
|
||||
4. Use **Save and check for Nightly updates**, or save the settings to apply the choice.
|
||||
5. Follow the update action shown for your installation. If the app offers a manual
|
||||
installation instead, use that path for your package format.
|
||||
6. After the update, open About again and check the installed version and build information.
|
||||
|
||||
An update result belongs to the channel that was checked. If you change the selection but
|
||||
have not saved it, the earlier “latest version” result does not tell you whether the newly
|
||||
selected channel has an update. Read the channel label next to the result.
|
||||
|
||||
If an update is already downloading or ready to install, changing the channel does not
|
||||
replace that download. The app identifies it as an update from the previous channel.
|
||||
|
||||
## Switching back to Stable
|
||||
|
||||
Choose **Stable** and save. Future checks now use the stable channel, but the installed
|
||||
nightly stays in place until there is a newer stable release.
|
||||
|
||||
For example, a nightly between two stable releases can contain database changes that the
|
||||
older stable app does not understand. Automatically installing that older version would
|
||||
put your existing library at risk. The updater therefore waits for a stable version that
|
||||
can take you forward.
|
||||
|
||||
Treat the switch as “follow stable releases from now on.” It is not an undo button for the
|
||||
last update, and reinstalling an older executable is not a reliable way to reverse a data
|
||||
migration.
|
||||
|
||||
## Installation format matters
|
||||
|
||||
The channel selector belongs to the desktop app. It does not update a self-hosted web
|
||||
server or its Docker image.
|
||||
|
||||
On desktop, follow the action About actually offers. Linux packages without AppImage have
|
||||
a manual-install path, and package-manager installations have their own update process.
|
||||
When asking for help, “Linux” alone is not enough: say whether you installed an AppImage,
|
||||
DEB, RPM, Flatpak, Snap, or another package.
|
||||
|
||||
## Report the result so it can be reproduced
|
||||
|
||||
A useful report can be short:
|
||||
|
||||
> Version/build: copy from About. OS and installation format: include both. Source/player:
|
||||
> M3U, Xtream or Stalker and the selected player. Steps: what you opened and clicked.
|
||||
> Expected: what should happen. Actual: what happened instead.
|
||||
|
||||
For update problems, say whether the build was not offered, failed to download, failed to
|
||||
install, or installed but behaved incorrectly. These are different points in the process.
|
||||
If the issue concerns missing favorites, also say whether it appeared immediately after
|
||||
the update or only after refreshing a playlist.
|
||||
|
||||
## Related
|
||||
|
||||
- [Stable releases](https://github.com/4gray/iptvnator/releases)
|
||||
- [Nightly builds](https://github.com/4gray/iptvnator-nightly/releases)
|
||||
- [Desktop installation options](/iptvnator/download/)
|
||||
- [The update-channel feature in 0.24](/iptvnator/blog/v0-24-release-notes/)
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: What Stream Info Can Tell You About Playback Problems
|
||||
description: Read resolution, frame rate, bitrate, buffer and dropped frames in IPTVnator's Stream info panel, then collect a useful playback report without guessing from a single number.
|
||||
pubDate: 2026-09-27
|
||||
author: 4gray
|
||||
tags:
|
||||
- guide
|
||||
- playback
|
||||
- troubleshooting
|
||||
draft: true
|
||||
faq:
|
||||
- q: Why are some Stream info values missing?
|
||||
a: Different engines and streams expose different information. IPTVnator hides unavailable values rather than presenting them as zero. Let playback start before checking the panel.
|
||||
- q: Why can Frame rate differ from Source frame rate?
|
||||
a: Source frame rate is information declared by the stream when available. Frame rate measures presented frames over time, so stalls and dropped frames can lower it.
|
||||
- q: Is stream bitrate an internet speed test?
|
||||
a: No. It describes the stream where that information is available. It does not measure the maximum speed of your connection or prove that a network problem is responsible for a stall.
|
||||
- q: Can I see Stream info in external VLC or MPV?
|
||||
a: Those players have their own diagnostic tools. IPTVnator's overlay is available through its shared controls for built-in web players and experimental frame-copy Embedded MPV.
|
||||
- q: What should I attach to a playback issue?
|
||||
a: Include the exact app version, OS, selected player, source type and reproduction steps. If playback shows an error, use Copy diagnostics. The generated report omits stream URLs and credentials; review any extra screenshots or text you add yourself.
|
||||
---
|
||||
|
||||
import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
|
||||
|
||||
A picture that freezes every few seconds and one that plays smoothly at a lower resolution
|
||||
are different problems. IPTVnator 0.24 adds **Stream info** so you can see what the player
|
||||
knows while the video is running, instead of describing both as “bad quality.”
|
||||
|
||||
The panel is useful for comparing behavior and reporting a problem. Its numbers are
|
||||
observations from the playback engine; no single value can identify every cause of a stall.
|
||||
|
||||
<ContentDisclaimer />
|
||||
|
||||
|
||||
## Open Stream info
|
||||
|
||||
1. Start a channel, movie or episode with a built-in web player, using unified controls,
|
||||
or with experimental frame-copy Embedded MPV.
|
||||
2. Reveal the player controls and click the information button labelled **Stream info**.
|
||||
3. Let the video run long enough for the reported values to settle, then observe what
|
||||
changes when the problem occurs.
|
||||
|
||||
The panel shows only values the current engine can report. A missing audio bitrate or
|
||||
source frame rate is not a zero reading. Immediately after starting, the panel may say
|
||||
there is no stream data yet.
|
||||
|
||||
## Read the values in context
|
||||
|
||||
| Field | What it helps you understand |
|
||||
| --- | --- |
|
||||
| Resolution | The dimensions reported for the video, rather than the size of the app window |
|
||||
| Frame rate | Frames actually presented over time; interruptions can lower the measurement |
|
||||
| Source frame rate | The stream's declared frame rate, when known |
|
||||
| Stream bitrate | Bitrate information for the stream; not a connection-speed test |
|
||||
| Video and audio | Codec and bitrate details when exposed by the engine |
|
||||
| Channels and sample rate | Information about the audio track |
|
||||
| Buffer | How much playable data lies ahead of the current position |
|
||||
| Dropped frames | Frames counted as dropped by the playback path |
|
||||
|
||||
Suppose a source declares 50 fps but the measured rate falls during a freeze. That tells
|
||||
you the player is not presenting frames at the declared pace during that interval. It does
|
||||
not, by itself, tell you whether the cause was delivery, decoding or another interruption.
|
||||
|
||||
Likewise, a dropped-frame count is more useful when you watch whether it keeps increasing.
|
||||
A count left over from startup is different from a counter rising throughout playback.
|
||||
|
||||
## Make one comparison at a time
|
||||
|
||||
Start with the same channel and the same section of a movie when possible. Note the player
|
||||
and what was happening when the fault appeared.
|
||||
|
||||
- If a stream offers several qualities, try a lower one and compare both smoothness and
|
||||
the available buffer readings.
|
||||
- If only one channel fails, compare another channel from the same source. Avoid assuming
|
||||
that a working channel proves every stream on that source is healthy.
|
||||
- If you try another player, record which engine you used. Different engines can expose
|
||||
different metrics, so compare playback behavior as well as the visible numbers.
|
||||
|
||||
Changing several settings at once makes it hard to tell which change helped. A short
|
||||
description such as “Video.js stalls on this channel; HTML5 plays it without that stall”
|
||||
is more actionable than a screenshot of one bitrate value.
|
||||
|
||||
## When playback fails completely
|
||||
|
||||
The error screen serves a different purpose from Stream info. It reports the failure the
|
||||
player observed and can offer recovery actions, such as retrying or trying another player.
|
||||
For background on compatibility, see
|
||||
[why some streams need an external player](/iptvnator/blog/why-external-players-help/).
|
||||
|
||||
Use **Copy diagnostics** when an error offers it. In 0.24, that report includes useful
|
||||
failure details without the stream URL or account credentials. It can distinguish a
|
||||
reported access or DRM failure from a generic playback problem without sending extra
|
||||
diagnostic requests to the provider.
|
||||
|
||||
Add your app version, OS, player, source type and reproduction steps alongside the report.
|
||||
If you attach a screenshot, check the surrounding UI for source addresses or account data;
|
||||
the redaction of a copied report does not redact an unrelated screenshot.
|
||||
|
||||
## Related
|
||||
|
||||
- [Why some streams need an external player](/iptvnator/blog/why-external-players-help/)
|
||||
- [Compare playback engines](/iptvnator/compare/playback-engines/)
|
||||
- [Stream info and diagnostics in 0.24](/iptvnator/blog/v0-24-release-notes/)
|
||||
- [Download IPTVnator](/iptvnator/download/)
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Task-based reading order. Article titles and descriptions stay in the blog collection. */
|
||||
export interface GuideGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
posts: string[];
|
||||
includeLatestRelease?: boolean;
|
||||
}
|
||||
|
||||
export const GUIDE_GROUPS: GuideGroup[] = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: 'Getting started',
|
||||
description: 'Connect your first source. Start with the kind of access you already have.',
|
||||
posts: ['m3u-playlist-epg-setup-guide', 'xtream-codes-setup-guide', 'stalker-portal-setup-guide'],
|
||||
},
|
||||
{
|
||||
id: 'live-tv-epg',
|
||||
title: 'Live TV & EPG',
|
||||
description: 'Find what is on, get the schedule right, and change channels from the sofa.',
|
||||
posts: ['m3u-programme-guide', 'epg-guide', 'epg-wrong-program-fix', 'remote-control-guide'],
|
||||
},
|
||||
{
|
||||
id: 'playback',
|
||||
title: 'Player & playback',
|
||||
description: 'Make the player work for you, from subtitles to a stream that will not start.',
|
||||
posts: ['fullscreen-channel-episode-guide', 'player-controls-guide', 'stream-info-diagnostics-guide', 'why-external-players-help'],
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
title: 'Your library',
|
||||
description: 'Organize your sources, keep your place, and take your library with you.',
|
||||
posts: ['library-organization-guide', 'playlist-backup-restore-guide', 'offline-downloads-guide', 'alternative-sources-guide', 'tmdb-metadata-guide'],
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
title: 'Updates',
|
||||
description: 'Choose an update channel and catch up on what changed in the latest release.',
|
||||
posts: ['stable-nightly-updates-guide'],
|
||||
includeLatestRelease: true,
|
||||
},
|
||||
];
|
||||
@@ -33,6 +33,9 @@ const remainingPosts = featuredPost ? posts.filter((post) => post.id !== feature
|
||||
<p class="mt-4 text-lg text-surface-400">
|
||||
Release notes, tutorials, and project updates.
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-surface-400">
|
||||
Looking for a how-to? <a href="/iptvnator/guides/" class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-300">Browse guides by task →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BlogTagRail posts={posts} class="mt-8" />
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { GUIDE_GROUPS } from '../../lib/guides';
|
||||
import { absoluteUrl, siteHref } from '../../lib/site';
|
||||
|
||||
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
|
||||
const allPosts = await getCollection('blog');
|
||||
const byId = new Map(allPosts.map((post) => [post.id, post]));
|
||||
const latestRelease = allPosts
|
||||
.filter((post) => !post.data.draft && post.data.tags.includes('release'))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())[0];
|
||||
const groups = GUIDE_GROUPS.map((group) => {
|
||||
const posts = group.posts.map((id) => {
|
||||
const post = byId.get(id);
|
||||
if (!post) throw new Error(`Guides group "${group.id}" references missing post "${id}".`);
|
||||
return post;
|
||||
}).filter((post) => includeDrafts || !post.data.draft);
|
||||
if (group.includeLatestRelease && latestRelease) posts.push(latestRelease);
|
||||
return { ...group, posts };
|
||||
}).filter((group) => group.posts.length > 0);
|
||||
const pageUrl = absoluteUrl('/guides/');
|
||||
const description = 'Practical IPTVnator guides organized by task: getting started, live TV and EPG, player controls, your library and updates.';
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'IPTVnator Guides',
|
||||
description,
|
||||
url: pageUrl,
|
||||
hasPart: groups.flatMap((group) => group.posts.map((post) => ({
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.data.title,
|
||||
url: absoluteUrl(`/blog/${post.id}/`),
|
||||
}))),
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'IPTVnator', item: absoluteUrl('/') },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Guides', item: pageUrl },
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Guides | IPTVnator" description={description} jsonLd={jsonLd}>
|
||||
<div class="mx-auto max-w-6xl px-6 pt-36 pb-24 sm:pt-40">
|
||||
<header class="max-w-3xl">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Guides</span>
|
||||
<h1 class="mt-4 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
|
||||
Get more from<br /><span class="text-accent-400">IPTVnator.</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-surface-400">
|
||||
Set up your first source, find something to watch, or get playback working
|
||||
the way you want. Pick a task and start there.
|
||||
</p>
|
||||
<p class="mt-5 text-sm text-surface-400">
|
||||
New to the app? <a href={siteHref('/download/')} class="guide-link">Download IPTVnator</a>
|
||||
or <a href={siteHref('/compare/m3u-vs-xtream-vs-stalker/')} class="guide-link">compare source types</a>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mt-14 grid gap-12 border-t border-surface-800 pt-8 lg:grid-cols-[220px_minmax(0,1fr)] lg:gap-16 lg:pt-12">
|
||||
<aside>
|
||||
<nav aria-label="Guide topics" class="lg:sticky lg:top-28">
|
||||
<p class="mb-4 font-mono text-[11px] uppercase tracking-[0.16em] text-surface-500">Find your task</p>
|
||||
<ol class="grid gap-1 sm:grid-cols-2 lg:grid-cols-1">
|
||||
{groups.map((group, index) => (
|
||||
<li>
|
||||
<a href={`#${group.id}`} class="topic-link flex items-center gap-3 rounded-lg px-3 py-3 text-sm text-surface-300 transition-colors hover:bg-surface-900 hover:text-accent-400">
|
||||
<span aria-hidden="true" class="font-mono text-[11px] text-accent-500">{String(index + 1).padStart(2, '0')}</span>
|
||||
<span class="flex-1">{group.title}</span>
|
||||
<span class="font-mono text-[11px] text-surface-500" aria-label={`${group.posts.length} articles`}>{group.posts.length}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p class="mt-6 border-t border-surface-800 pt-5 text-sm leading-relaxed text-surface-500">
|
||||
Looking for an overview?
|
||||
<a href={siteHref('/features/')} class="guide-link mt-1 block">Explore the features →</a>
|
||||
</p>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 space-y-16">
|
||||
{groups.map((group, index) => (
|
||||
<section id={group.id} aria-labelledby={`${group.id}-title`} class="guide-group scroll-mt-28">
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span aria-hidden="true" class="font-mono text-xs text-accent-500">{String(index + 1).padStart(2, '0')}</span>
|
||||
<h2 id={`${group.id}-title`} class="font-display text-2xl font-semibold tracking-tight text-surface-50 sm:text-3xl">{group.title}</h2>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-relaxed text-surface-400">{group.description}</p>
|
||||
<ul class="mt-6 divide-y divide-surface-800 border-y border-surface-800">
|
||||
{group.posts.map((post) => (
|
||||
<li>
|
||||
<a href={siteHref(`/blog/${post.id}/`)} data-guide-article={post.id} class="guide-article group -mx-3 flex gap-5 rounded-lg px-3 py-6 transition-colors hover:bg-surface-900/60">
|
||||
<div class="min-w-0 flex-1">
|
||||
{(post.data.draft || post.data.tags.includes('release')) && (
|
||||
<span class="mb-2 block font-mono text-[10px] uppercase tracking-[0.12em] text-accent-500">
|
||||
{post.data.draft ? 'Draft preview' : 'Release notes'}
|
||||
</span>
|
||||
)}
|
||||
<h3 class="font-display text-lg font-semibold leading-snug text-surface-100 transition-colors group-hover:text-accent-400">{post.data.title}</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-surface-400">{post.data.description}</p>
|
||||
</div>
|
||||
<svg class="mt-1 h-5 w-5 shrink-0 text-surface-600 transition-colors group-hover:text-accent-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 17 17 7M7 7h10v10" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* Clip horizontal decoration without creating a scroll container for the topic nav. */
|
||||
:global(main:has(.guide-group)) {
|
||||
overflow-x: clip;
|
||||
}
|
||||
.guide-link {
|
||||
@apply text-accent-400 underline decoration-accent-500/30 underline-offset-4 hover:text-accent-300;
|
||||
}
|
||||
.guide-link:focus-visible,
|
||||
.topic-link:focus-visible,
|
||||
.guide-article:focus-visible {
|
||||
outline: 2px solid theme('colors.accent.400');
|
||||
outline-offset: 4px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(html:has(.guide-group)) {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -232,6 +232,29 @@ sub-entrypoints are `@iptvnator/shared/interfaces/ipc-commands` and
|
||||
`@iptvnator/shared/interfaces` barrel pulls in `ngx-indexed-db`, which the
|
||||
preload bundle must not carry, so the preload only type-imports the barrel
|
||||
and value-imports those two dependency-free modules directly.
|
||||
`@iptvnator/workspace/shell/util/settings-search` exists for the opposite
|
||||
reason: the main `workspace-shell-util` barrel is imported eagerly, and the
|
||||
settings search index must stay in the lazy settings and shell chunks.
|
||||
|
||||
The web app's root shell (`app.component.ts`, `app.config.ts` and the services
|
||||
they construct) is on the renderer's initial path, where a barrel costs its
|
||||
whole library: esbuild keeps every Angular component module a barrel
|
||||
re-exports, because their static definitions count as side effects. Importing
|
||||
`WindowControlsComponent` and `DialogService` from `@iptvnator/ui/components`
|
||||
once put the channel lists, EPG views, `@angular/forms`, `date-fns` and the
|
||||
Stalker data layer into `main.js`. The root shell therefore uses file-level
|
||||
entries: `@iptvnator/ui/components/window-controls`,
|
||||
`@iptvnator/ui/components/confirm-dialog` and
|
||||
`@iptvnator/playlist/shared/ui/stalker-connection-editor` (like the existing
|
||||
`@iptvnator/ui/epg/progress-panel`), and loads anything used only on demand
|
||||
through a local file it imports dynamically (the Stalker connection editor,
|
||||
the release-notes dialog, the external-player info dialog). A local file,
|
||||
not the library alias, is the dynamic-import target because
|
||||
`@nx/enforce-module-boundaries` forbids static imports of a library the same
|
||||
project also loads dynamically. `renderer.initialBytes` in
|
||||
[performance journeys](performance-journeys.md) guards the result; to see why a
|
||||
module is eager, build with `pnpm nx build web --stats-json` and follow the
|
||||
static imports in `dist/apps/web/stats.json` from `apps/web/src/main.ts`.
|
||||
|
||||
For a buildable library that has a local `package.json`, its `name` must match
|
||||
the scoped alias. Nx uses that package name when rewriting buildable dependency
|
||||
|
||||
@@ -82,6 +82,28 @@ type-checking each through a language service; spec type errors therefore do
|
||||
not fail Jest (the web configs already ran with `diagnostics: false`), while
|
||||
`isolatedModules`-incompatible syntax such as a type re-export without
|
||||
`export type` still fails at load time.
|
||||
|
||||
In CI, a pull request skips the Tier A suite (and the merged-coverage upload)
|
||||
when every changed file is outside Tier A test inputs:
|
||||
`tools/coverage/unit-coverage-scope.mjs` holds the allowlist (Markdown, `docs/`,
|
||||
notes and plans, agent guidance, workflows other than `ci.yml` and
|
||||
`build-and-make.yaml`, the website, E2E and mock-server apps,
|
||||
release/packaging/skills/performance tooling, and a `package.json` edit
|
||||
confined to non-`coverage:*` scripts). Anything else, including files no Nx
|
||||
project owns such as `jest.preset.js` or `tsconfig.base.json`, and every
|
||||
`{workspaceRoot}` input a Tier A test target declares, runs the full suite;
|
||||
master pushes always run it. `nx affected` is deliberately not used for this
|
||||
decision because a change to an unowned file affects no project. A node test
|
||||
parses every Tier A source for string literals that point at repository files
|
||||
outside the owning project (`tier-a-external-references.mjs`) and fails if the
|
||||
allowlist would skip any of them, so a new cross-project read cannot be
|
||||
silently exempted.
|
||||
Jest's transform cache is kept in `JEST_CACHE_DIRECTORY` and persisted with
|
||||
`actions/cache`: pull requests restore it, while only master pushes (starting
|
||||
from an empty cache, so it holds exactly the current tree) and maintainer
|
||||
dispatches save it. A shared Nx task cache is not used: Nx indexes its local
|
||||
cache in a machine-specific database, so a restored cache folder is never hit,
|
||||
and sharing it safely needs Nx Cloud or another supported remote cache.
|
||||
`coverage:merge` requires every configured Tier A report before replacing the
|
||||
merged output. Strict health validation also requires the merged Istanbul map
|
||||
itself to contain usable instrumentation for every runtime-owning Tier A file,
|
||||
@@ -146,8 +168,9 @@ After an E2E run, generate the semantic summary with:
|
||||
pnpm run coverage:e2e:summary
|
||||
```
|
||||
|
||||
CI runs the Electron suite as three Playwright shards per OS
|
||||
(`--shard=<n>/3`, split by spec file because the suite is sequential). Each
|
||||
CI runs the Electron suite as Playwright shards (`--shard=<n>/<total>`, split
|
||||
by spec file because the suite is sequential): three shards on Ubuntu and
|
||||
Windows, two on macOS, whose runners are the scarcest and queued longest. Each
|
||||
shard uploads `playwright-report-electron-<os>-<n>`; the follow-up
|
||||
`Electron E2E summary` job downloads the shards of each OS into their own
|
||||
directory and runs the summary per OS with `--input=<directory>` and
|
||||
|
||||
@@ -169,7 +169,8 @@ restarting playback; remote commands retain captured playback order. See the
|
||||
|
||||
Search is shell-owned and route-aware:
|
||||
|
||||
1. Disabled on settings routes.
|
||||
1. On settings routes, searches the settings themselves (see
|
||||
[Settings search](#settings-search)).
|
||||
2. Enabled on sources routes.
|
||||
3. Enabled for `/workspace/search`, which is the Electron-only routed
|
||||
global-search view. `Ctrl/Cmd+F` in Electron opens this route and
|
||||
@@ -214,8 +215,10 @@ Rail navigation is also shell-owned:
|
||||
|
||||
Command palette behavior is shell-owned but view-extensible:
|
||||
|
||||
1. The shell resolves commands into three groups in fixed order: current view,
|
||||
this playlist, then global.
|
||||
1. The shell resolves commands into groups in fixed order: current view,
|
||||
this playlist, global, then settings. The settings group appears only for a
|
||||
non-empty query and holds at most six settings matches (see
|
||||
[Settings search](#settings-search)).
|
||||
2. Shell-owned commands are derived from route context and current playlist
|
||||
state; empty groups are omitted instead of rendering disabled placeholders.
|
||||
3. Workspace features contribute current-view commands through
|
||||
@@ -245,6 +248,51 @@ Command palette behavior is shell-owned but view-extensible:
|
||||
is disabled. The new player setting applies to the next playback session; an
|
||||
existing stream is not re-mounted.
|
||||
|
||||
### Settings search
|
||||
|
||||
Settings rows are searchable from the header search on `/workspace/settings`
|
||||
and from the command palette. Both use the same index and ranking.
|
||||
|
||||
1. The index is `SETTINGS_SEARCH_ENTRIES` in
|
||||
`libs/workspace/shell/util/src/lib/settings-search/`, published through the
|
||||
`@iptvnator/workspace/shell/util/settings-search` sub-entrypoint. Eager
|
||||
code imports the main shell util barrel, so the index stays out of it and
|
||||
ships only in lazy chunks (the initial-bytes ratchet enforces this).
|
||||
2. Each entry names its section, title and description translation keys,
|
||||
untranslated synonyms (`keywords`), runtime `requires`, and an optional
|
||||
`fallbackId`. Section definitions (`SETTINGS_SECTION_DEFINITIONS`) are the
|
||||
single source for the settings navigation too.
|
||||
3. Every titled `.setting-item` in the section templates carries
|
||||
`data-setting-id`. `settings-search-registry.spec.ts` fails when a row, id,
|
||||
title key or description key drifts from the index, so a new settings row
|
||||
must be added to the index in the same change.
|
||||
4. `SettingsSearchService.search()` matches the translated title and
|
||||
description of the current language plus the keywords; every query token
|
||||
must match (AND), and a label prefix outranks a word start, which outranks
|
||||
an inner match. Rows whose `requires` the runtime lacks are never returned.
|
||||
Embedded MPV rows depend on a lazy support probe
|
||||
(`ensureEmbeddedMpvSupportLoaded()`), run when the settings page or the
|
||||
command palette opens, never from shell bootstrap; frame copy also needs
|
||||
`frameCopyAvailable`, matching the settings page gate.
|
||||
5. Settings routes use `local-filter` search mode, so the term lives in `q`.
|
||||
While `q` is set, the settings page shows ranked results in place of the
|
||||
section page and the settings context panel shows per-section match
|
||||
counts, muting sections without matches. The search box is shown on
|
||||
settings even when no playlist exists.
|
||||
6. Choosing a result, pressing `Enter` in the header search (best match), or
|
||||
picking a settings command in the palette calls `reveal()`: it navigates
|
||||
to the section page without `q` (which clears the box) and the page
|
||||
scrolls to, focuses, and briefly highlights the row once the form is
|
||||
hydrated. A row hidden by the current form state falls back to its
|
||||
`fallbackId`, the control that makes it appear. A reveal must win over the
|
||||
typed term: `WorkspaceShellSearchSyncService` drops a keystroke still
|
||||
waiting for its debounce through `onReveal()`, and Enter does not apply
|
||||
the term first, because either `q` sync navigation would supersede the
|
||||
reveal navigation. Keyboard users keep a `:focus-visible` ring on the row
|
||||
after the highlight fades.
|
||||
7. `Ctrl/Cmd+F` on settings focuses the header search instead of opening
|
||||
global search.
|
||||
|
||||
Keyboard shortcut help is shell-owned:
|
||||
|
||||
1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`.
|
||||
|
||||
@@ -91,6 +91,13 @@ exit. The retained handle still provides the actual exit code and signal.
|
||||
The Linux portable build uploads `packaged-frame-copy-smoke` reports and traces
|
||||
even when the smoke fails. Check the paused-frame screenshot and trace before
|
||||
classifying a zero rendered-frame signal as an infrastructure flake.
|
||||
The Snap and Flatpak `--embedded-mpv-runtime-probe` launches in
|
||||
`build-and-make.yaml` run under GNU `timeout --verbose -k 10 300` with
|
||||
`ELECTRON_ENABLE_LOGGING=1`, so a main process that throws before app ready
|
||||
and blocks on Electron's uncaught-exception dialog under xvfb fails within
|
||||
five minutes with a distinct `::error::` (exit 124, or 137 after timeout's
|
||||
announced KILL escalation) and its stderr in the step log, instead of holding
|
||||
the job until the 120-minute limit.
|
||||
|
||||
## Main-process ownership
|
||||
|
||||
|
||||
@@ -12,8 +12,16 @@ const collectCoverageFrom = [
|
||||
'!src/**/index.ts',
|
||||
];
|
||||
|
||||
// CI points every Jest run at one directory (JEST_CACHE_DIRECTORY) so the
|
||||
// transform cache can be persisted between workflow runs; unset, Jest keeps
|
||||
// its default per-user temp directory.
|
||||
const cacheDirectory = process.env.JEST_CACHE_DIRECTORY
|
||||
? { cacheDirectory: process.env.JEST_CACHE_DIRECTORY }
|
||||
: {};
|
||||
|
||||
module.exports = {
|
||||
...nxPreset,
|
||||
...cacheDirectory,
|
||||
coverageReporters,
|
||||
collectCoverageFrom,
|
||||
};
|
||||
@@ -11,6 +11,10 @@ const coverageReporters = ['json', 'json-summary', 'lcovonly', 'text-summary'];
|
||||
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 }
|
||||
: {}),
|
||||
rootDir: '.',
|
||||
roots: ['<rootDir>/apps/web', '<rootDir>/libs'],
|
||||
// Jest's 5s default is thin for Angular component specs: TestBed compiles
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export type WorkspaceCommandGroup = 'view' | 'playlist' | 'global';
|
||||
/**
|
||||
* `settings` holds one command per searchable settings row. The palette
|
||||
* lists them only for a non-empty query, ranked and capped, so they never
|
||||
* flood the default command list.
|
||||
*/
|
||||
export type WorkspaceCommandGroup = 'view' | 'playlist' | 'global' | 'settings';
|
||||
|
||||
export interface WorkspaceCommandActionContext {
|
||||
query: string;
|
||||
|
||||
+146
@@ -381,3 +381,149 @@ describe('WorkspaceCommandPaletteComponent - recent section', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceCommandPaletteComponent - settings group', () => {
|
||||
function settingsCommand(
|
||||
id: string,
|
||||
label: string
|
||||
): WorkspaceResolvedCommandItem {
|
||||
return {
|
||||
id: `settings:${id}`,
|
||||
label,
|
||||
description: 'Playback',
|
||||
group: 'settings',
|
||||
icon: 'play_circle',
|
||||
keywords: [],
|
||||
priority: 100,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const commands: WorkspaceResolvedCommandItem[] = [
|
||||
{
|
||||
id: 'open-settings',
|
||||
label: 'Open settings',
|
||||
description: '',
|
||||
group: 'global',
|
||||
icon: 'settings',
|
||||
keywords: ['settings'],
|
||||
priority: 50,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => undefined,
|
||||
},
|
||||
settingsCommand('video-player', 'Video player'),
|
||||
settingsCommand('theme', 'Theme'),
|
||||
];
|
||||
|
||||
function setup(options: {
|
||||
query: string;
|
||||
recentIds?: readonly string[];
|
||||
searchSettings?: jest.Mock;
|
||||
}) {
|
||||
const searchSettings =
|
||||
options.searchSettings ??
|
||||
jest.fn((query: string) =>
|
||||
query.includes('player') ? ['settings:video-player'] : []
|
||||
);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [WorkspaceCommandPaletteComponent],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
query: options.query,
|
||||
commands,
|
||||
recentIds: options.recentIds ?? [],
|
||||
searchSettings,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(
|
||||
WorkspaceCommandPaletteComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return {
|
||||
component: fixture.componentInstance,
|
||||
fixture,
|
||||
searchSettings,
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps settings out of the list while the query is empty', () => {
|
||||
const { component, searchSettings } = setup({ query: '' });
|
||||
|
||||
expect(component.flatCommands().map((command) => command.id)).toEqual([
|
||||
'open-settings',
|
||||
]);
|
||||
expect(searchSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists settings matches after the command groups, in search order', () => {
|
||||
const searchSettings = jest.fn(() => [
|
||||
'settings:theme',
|
||||
'settings:video-player',
|
||||
]);
|
||||
const { component, fixture } = setup({
|
||||
query: 'settings',
|
||||
searchSettings,
|
||||
});
|
||||
|
||||
const groups = component.commandGroups();
|
||||
expect(groups.map((group) => group.group)).toEqual([
|
||||
'global',
|
||||
'settings',
|
||||
]);
|
||||
expect(groups[1].items.map((item) => item.id)).toEqual([
|
||||
'settings:theme',
|
||||
'settings:video-player',
|
||||
]);
|
||||
expect(searchSettings).toHaveBeenLastCalledWith('settings');
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS'
|
||||
);
|
||||
});
|
||||
|
||||
it('ranks settings only through the search callback, never by substring', () => {
|
||||
const { component } = setup({ query: 'theme' });
|
||||
|
||||
// "Theme" contains the query, but the search callback decides.
|
||||
expect(component.flatCommands()).toEqual([]);
|
||||
|
||||
component.query.set('player');
|
||||
expect(component.flatCommands().map((command) => command.id)).toEqual([
|
||||
'settings:video-player',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a recently opened setting in the recent section', () => {
|
||||
const { component } = setup({
|
||||
query: '',
|
||||
recentIds: ['settings:theme'],
|
||||
});
|
||||
|
||||
const [recent] = component.commandGroups();
|
||||
expect(recent.group).toBe('recent');
|
||||
expect(recent.items.map((item) => item.id)).toEqual(['settings:theme']);
|
||||
});
|
||||
});
|
||||
+40
-2
@@ -23,10 +23,15 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { foldSearchText } from '@iptvnator/shared/interfaces';
|
||||
|
||||
interface WorkspaceCommandPaletteData {
|
||||
export interface WorkspaceCommandPaletteData {
|
||||
commands: WorkspaceResolvedCommandItem[];
|
||||
query?: string;
|
||||
recentIds?: readonly string[];
|
||||
/**
|
||||
* Ranked ids of the `settings` group commands matching a query. Settings
|
||||
* commands are listed only through this, never for an empty query.
|
||||
*/
|
||||
searchSettings?: (query: string) => readonly string[];
|
||||
}
|
||||
|
||||
type PaletteSectionGroup = WorkspaceCommandGroup | 'recent';
|
||||
@@ -65,7 +70,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
|
||||
readonly filteredCommands = computed(() => {
|
||||
const term = foldSearchText(this.query().trim());
|
||||
const commands = this.visibleCommands();
|
||||
const commands = this.visibleCommands().filter(
|
||||
(command) => command.group !== 'settings'
|
||||
);
|
||||
|
||||
if (!term) {
|
||||
return commands;
|
||||
@@ -107,6 +114,28 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
}
|
||||
);
|
||||
|
||||
readonly settingsSection = computed<WorkspaceCommandGroupSection | null>(
|
||||
() => {
|
||||
const query = this.query().trim();
|
||||
const searchSettings = this.data?.searchSettings;
|
||||
if (!query || !searchSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const byId = new Map(
|
||||
this.visibleCommands().map((command) => [command.id, command])
|
||||
);
|
||||
const items = searchSettings(query)
|
||||
.map((id) => byId.get(id))
|
||||
.filter(
|
||||
(command): command is WorkspaceResolvedCommandItem =>
|
||||
command?.group === 'settings'
|
||||
);
|
||||
|
||||
return items.length === 0 ? null : { group: 'settings', items };
|
||||
}
|
||||
);
|
||||
|
||||
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
|
||||
const commands = this.filteredCommands();
|
||||
const recent = this.recentSection();
|
||||
@@ -144,6 +173,12 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
);
|
||||
|
||||
sections.push(...groups);
|
||||
|
||||
const settings = this.settingsSection();
|
||||
if (settings) {
|
||||
sections.push(settings);
|
||||
}
|
||||
|
||||
return sections;
|
||||
});
|
||||
|
||||
@@ -259,6 +294,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
|
||||
if (group === 'playlist') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
|
||||
}
|
||||
if (group === 'settings') {
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS';
|
||||
}
|
||||
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -45,6 +45,11 @@
|
||||
border-top: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
// Sections without settings-search matches stay reachable but recede.
|
||||
.settings-section-item.has-no-matches:not(.active) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.settings-section-item:hover:not(.active):not(.selected),
|
||||
.settings-back-button:hover:not(.active):not(.selected) {
|
||||
background: var(--app-selection-surface);
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
|
||||
|
||||
describe('WorkspaceSettingsContextPanelComponent', () => {
|
||||
function setup() {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [WorkspaceSettingsContextPanelComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const ctx = TestBed.inject(SettingsContextService);
|
||||
ctx.setSections([
|
||||
{ id: 'general', label: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
|
||||
{
|
||||
id: 'playback',
|
||||
label: 'SETTINGS.NAV_PLAYBACK',
|
||||
icon: 'play_circle',
|
||||
},
|
||||
]);
|
||||
const fixture = TestBed.createComponent(
|
||||
WorkspaceSettingsContextPanelComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const link = (id: string) =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
`[data-test-id="settings-section-${id}"]`
|
||||
) as HTMLElement;
|
||||
return { ctx, fixture, link };
|
||||
}
|
||||
|
||||
it('shows no counts while settings search is idle', () => {
|
||||
const { link } = setup();
|
||||
|
||||
expect(link('general').querySelector('.nav-item-meta')).toBeNull();
|
||||
expect(link('general').classList).not.toContain('has-no-matches');
|
||||
});
|
||||
|
||||
it('shows per-section match counts and mutes sections without matches', () => {
|
||||
const { ctx, fixture, link } = setup();
|
||||
|
||||
ctx.setMatchCounts({ general: 2 });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
link('general').querySelector('.nav-item-meta')?.textContent
|
||||
).toBe('2');
|
||||
expect(
|
||||
link('playback').querySelector('.nav-item-meta')?.textContent
|
||||
).toBe('0');
|
||||
expect(link('general').classList).not.toContain('has-no-matches');
|
||||
expect(link('playback').classList).toContain('has-no-matches');
|
||||
|
||||
ctx.reset();
|
||||
fixture.detectChanges();
|
||||
expect(ctx.matchCounts()).toBeNull();
|
||||
});
|
||||
});
|
||||
+18
-1
@@ -28,10 +28,27 @@ import {
|
||||
[routerLink]="['/workspace/settings', section.id]"
|
||||
[replaceUrl]="true"
|
||||
[attr.data-test-id]="'settings-section-' + section.id"
|
||||
[class.has-no-matches]="
|
||||
ctx.matchCounts() !== null &&
|
||||
!ctx.matchCounts()?.[section.id]
|
||||
"
|
||||
(click)="onSectionClicked()"
|
||||
>
|
||||
<mat-icon>{{ section.icon }}</mat-icon>
|
||||
<span>{{ section.label | translate }}</span>
|
||||
<span class="nav-item-label">{{
|
||||
section.label | translate
|
||||
}}</span>
|
||||
<!-- While a settings search is active, each section
|
||||
shows how many of its settings match. -->
|
||||
@if (ctx.matchCounts(); as counts) {
|
||||
<span
|
||||
class="nav-item-meta"
|
||||
[attr.data-test-id]="
|
||||
'settings-section-matches-' + section.id
|
||||
"
|
||||
>{{ counts[section.id] ?? 0 }}</span
|
||||
>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -42,7 +42,9 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!hasNoPlaylists()) {
|
||||
<!-- Settings search works without any playlist, so a fresh user can
|
||||
find settings before importing a first source. -->
|
||||
@if (!hasNoPlaylists() || isSettingsRoute()) {
|
||||
<label class="search-field">
|
||||
<mat-icon>search</mat-icon>
|
||||
@if (searchScopeLabel()) {
|
||||
|
||||
+15
@@ -229,6 +229,21 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('hides search without playlists except on the settings route', () => {
|
||||
const searchInput = () =>
|
||||
fixture.nativeElement.querySelector('input[type="search"]');
|
||||
|
||||
fixture.componentRef.setInput('hasNoPlaylists', true);
|
||||
fixture.detectChanges();
|
||||
expect(searchInput()).toBeNull();
|
||||
|
||||
// Settings search needs no playlist: a fresh user can still search.
|
||||
fixture.componentRef.setInput('isSettingsRoute', true);
|
||||
fixture.detectChanges();
|
||||
expect(searchInput()).not.toBeNull();
|
||||
expect(searchInput().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('renders scope and status chips when search metadata is provided', () => {
|
||||
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
|
||||
fixture.componentRef.setInput(
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
SettingsSearchEntry,
|
||||
SettingsSearchResult,
|
||||
SettingsSearchService,
|
||||
SETTINGS_SECTION_DEFINITIONS,
|
||||
} from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import {
|
||||
buildSettingsPaletteCommands,
|
||||
SETTINGS_PALETTE_RESULT_LIMIT,
|
||||
} from './workspace-settings-commands';
|
||||
|
||||
const THEME: SettingsSearchEntry = {
|
||||
id: 'theme',
|
||||
section: 'general',
|
||||
labelKey: 'SETTINGS.THEME',
|
||||
keywords: ['dark'],
|
||||
};
|
||||
const PLAYER: SettingsSearchEntry = {
|
||||
id: 'video-player',
|
||||
section: 'playback',
|
||||
labelKey: 'SETTINGS.VIDEO_PLAYER_LABEL',
|
||||
};
|
||||
|
||||
function createSettingsSearch() {
|
||||
return {
|
||||
visibleEntries: jest.fn(() => [THEME, PLAYER]),
|
||||
search: jest.fn((): SettingsSearchResult[] => [
|
||||
{
|
||||
entry: PLAYER,
|
||||
section: SETTINGS_SECTION_DEFINITIONS[1],
|
||||
label: 'Video player',
|
||||
description: '',
|
||||
sectionLabel: 'Playback',
|
||||
score: 10,
|
||||
},
|
||||
]),
|
||||
reveal: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildSettingsPaletteCommands', () => {
|
||||
const translate = (key: string) => `t(${key})`;
|
||||
|
||||
it('resolves one settings-group command per visible row', () => {
|
||||
const settingsSearch = createSettingsSearch();
|
||||
|
||||
const { commands } = buildSettingsPaletteCommands(
|
||||
settingsSearch as unknown as SettingsSearchService,
|
||||
translate
|
||||
);
|
||||
|
||||
expect(commands).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'settings:theme',
|
||||
group: 'settings',
|
||||
icon: 'tune',
|
||||
label: 't(SETTINGS.THEME)',
|
||||
description: 't(SETTINGS.NAV_GENERAL)',
|
||||
keywords: ['dark'],
|
||||
visible: true,
|
||||
enabled: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'settings:video-player',
|
||||
icon: 'play_circle',
|
||||
description: 't(SETTINGS.NAV_PLAYBACK)',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reveals the row when a command runs', () => {
|
||||
const settingsSearch = createSettingsSearch();
|
||||
const { commands } = buildSettingsPaletteCommands(
|
||||
settingsSearch as unknown as SettingsSearchService,
|
||||
translate
|
||||
);
|
||||
|
||||
commands[1].run({ query: 'player' });
|
||||
|
||||
expect(settingsSearch.reveal).toHaveBeenCalledWith(PLAYER);
|
||||
});
|
||||
|
||||
it('delegates ranking to the settings search with the palette cap', () => {
|
||||
const settingsSearch = createSettingsSearch();
|
||||
const { search } = buildSettingsPaletteCommands(
|
||||
settingsSearch as unknown as SettingsSearchService,
|
||||
translate
|
||||
);
|
||||
|
||||
expect(search('player')).toEqual(['settings:video-player']);
|
||||
expect(settingsSearch.search).toHaveBeenCalledWith(
|
||||
'player',
|
||||
SETTINGS_PALETTE_RESULT_LIMIT
|
||||
);
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
SETTINGS_SECTION_DEFINITIONS,
|
||||
SettingsSearchService,
|
||||
} from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { TranslateFn } from './workspace-shell-search-labels';
|
||||
|
||||
/** Palette rows reserved for settings matches, so commands stay visible. */
|
||||
export const SETTINGS_PALETTE_RESULT_LIMIT = 6;
|
||||
|
||||
const SETTINGS_COMMAND_PREFIX = 'settings:';
|
||||
|
||||
export interface SettingsPaletteCommands {
|
||||
/** One command per visible settings row, for recents and selection. */
|
||||
readonly commands: WorkspaceResolvedCommandItem[];
|
||||
/** Ids of the best settings commands for `query`, best first. */
|
||||
readonly search: (query: string) => readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings rows as command palette entries. Ranking is delegated to
|
||||
* `SettingsSearchService` so the palette and the settings page search agree
|
||||
* on which settings match and in which order.
|
||||
*/
|
||||
export function buildSettingsPaletteCommands(
|
||||
settingsSearch: SettingsSearchService,
|
||||
translate: TranslateFn
|
||||
): SettingsPaletteCommands {
|
||||
const sections = new Map(
|
||||
SETTINGS_SECTION_DEFINITIONS.map((section) => [section.id, section])
|
||||
);
|
||||
|
||||
const commands = settingsSearch
|
||||
.visibleEntries()
|
||||
.map((entry): WorkspaceResolvedCommandItem => {
|
||||
const section = sections.get(entry.section);
|
||||
return {
|
||||
id: `${SETTINGS_COMMAND_PREFIX}${entry.id}`,
|
||||
group: 'settings',
|
||||
icon: section?.icon ?? 'settings',
|
||||
label: translate(entry.labelKey),
|
||||
description: section ? translate(section.navLabelKey) : '',
|
||||
keywords: entry.keywords ?? [],
|
||||
priority: 100,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
run: () => settingsSearch.reveal(entry),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
commands,
|
||||
search: (query) =>
|
||||
settingsSearch
|
||||
.search(query, SETTINGS_PALETTE_RESULT_LIMIT)
|
||||
.map(({ entry }) => `${SETTINGS_COMMAND_PREFIX}${entry.id}`),
|
||||
};
|
||||
}
|
||||
+2
@@ -468,6 +468,8 @@ export function getCommandGroupOrder(
|
||||
return 0;
|
||||
case 'playlist':
|
||||
return 1;
|
||||
case 'settings':
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ export const FILTER_SECTION_PLACEHOLDER =
|
||||
'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
|
||||
export const SEARCH_SOURCES_PLACEHOLDER =
|
||||
'WORKSPACE.SHELL.SEARCH_SOURCES_PLACEHOLDER';
|
||||
export const SEARCH_SETTINGS_PLACEHOLDER =
|
||||
'WORKSPACE.SHELL.SEARCH_SETTINGS_PLACEHOLDER';
|
||||
export const SEARCH_LOADED_ONLY_STATUS =
|
||||
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY';
|
||||
|
||||
|
||||
+28
-11
@@ -6,9 +6,14 @@ import {
|
||||
WorkspaceResolvedCommandItem,
|
||||
WorkspaceViewCommandService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
|
||||
import {
|
||||
WorkspaceCommandPaletteComponent,
|
||||
WorkspaceCommandPaletteData,
|
||||
} from '../../workspace-command-palette/workspace-command-palette.component';
|
||||
import { RecentCommandsService } from '../../recent-commands';
|
||||
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { buildSettingsPaletteCommands } from './helpers/workspace-settings-commands';
|
||||
import {
|
||||
buildCommandPaletteItems,
|
||||
CommandBuilderContext,
|
||||
@@ -23,6 +28,7 @@ export class WorkspaceShellCommandPaletteService {
|
||||
private readonly playerCommands = inject(
|
||||
WorkspacePlayerCommandsContributor
|
||||
);
|
||||
private readonly settingsSearch = inject(SettingsSearchService);
|
||||
|
||||
private commandPaletteRef: MatDialogRef<
|
||||
WorkspaceCommandPaletteComponent,
|
||||
@@ -46,11 +52,18 @@ export class WorkspaceShellCommandPaletteService {
|
||||
return;
|
||||
}
|
||||
|
||||
const embeddedMpvSupportLoad =
|
||||
this.playerCommands.ensureEmbeddedMpvSupportLoaded();
|
||||
if (embeddedMpvSupportLoad) {
|
||||
const embeddedMpvSupportLoads = [
|
||||
this.playerCommands.ensureEmbeddedMpvSupportLoaded(),
|
||||
this.settingsSearch.ensureEmbeddedMpvSupportLoaded(),
|
||||
].filter((load): load is Promise<void> => load !== undefined);
|
||||
if (embeddedMpvSupportLoads.length > 0) {
|
||||
this.commandPaletteOpening = true;
|
||||
void embeddedMpvSupportLoad.finally(() => {
|
||||
// A failed probe only hides its commands; it must not block opening.
|
||||
void Promise.all(
|
||||
embeddedMpvSupportLoads.map((load) =>
|
||||
load.catch(() => undefined)
|
||||
)
|
||||
).finally(() => {
|
||||
this.commandPaletteOpening = false;
|
||||
this.openResolvedCommandPalette(ctx, initialQuery);
|
||||
});
|
||||
@@ -68,17 +81,20 @@ export class WorkspaceShellCommandPaletteService {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = this.buildPaletteCommands(ctx);
|
||||
const settings = buildSettingsPaletteCommands(
|
||||
this.settingsSearch,
|
||||
ctx.translate
|
||||
);
|
||||
const commands = [
|
||||
...this.buildPaletteCommands(ctx),
|
||||
...settings.commands,
|
||||
];
|
||||
const recentIds = this.recentCommands
|
||||
.entries()
|
||||
.map((entry) => entry.id);
|
||||
const dialogRef = this.dialog.open<
|
||||
WorkspaceCommandPaletteComponent,
|
||||
{
|
||||
commands: WorkspaceResolvedCommandItem[];
|
||||
query: string;
|
||||
recentIds: readonly string[];
|
||||
},
|
||||
WorkspaceCommandPaletteData,
|
||||
WorkspaceCommandSelection | undefined
|
||||
>(WorkspaceCommandPaletteComponent, {
|
||||
width: 'min(760px, 92vw)',
|
||||
@@ -89,6 +105,7 @@ export class WorkspaceShellCommandPaletteService {
|
||||
commands,
|
||||
query: initialQuery,
|
||||
recentIds,
|
||||
searchSettings: settings.search,
|
||||
},
|
||||
});
|
||||
this.commandPaletteRef = dialogRef;
|
||||
|
||||
+16
@@ -9,6 +9,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
|
||||
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
|
||||
@@ -133,6 +134,21 @@ describe('WorkspaceShellSearchSyncService', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('drops a still-debouncing keystroke when a settings result is opened', () => {
|
||||
// Typing one more letter and clicking a visible settings result
|
||||
// within the debounce window: applying that keystroke afterwards
|
||||
// would start a `q` navigation that supersedes the reveal.
|
||||
service.onSearchInput('them');
|
||||
TestBed.inject(SettingsSearchService).reveal({
|
||||
id: 'theme',
|
||||
section: 'general',
|
||||
labelKey: 'SETTINGS.THEME',
|
||||
});
|
||||
jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS);
|
||||
|
||||
expect(service.appliedSearchQuery()).toBe('');
|
||||
});
|
||||
|
||||
it('keeps in-flight typing when the page writes an unrelated query param', () => {
|
||||
// The downloads filter chips write `?filter=…` with replaceUrl. Under
|
||||
// load that navigation can land after the first keystroke — it must
|
||||
|
||||
+8
@@ -5,6 +5,7 @@ import { filter } from 'rxjs';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
|
||||
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
|
||||
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
|
||||
import {
|
||||
getRoutePath,
|
||||
@@ -30,6 +31,13 @@ export class WorkspaceShellSearchSyncService {
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.cancelPendingSearchApply());
|
||||
// Opening a settings search result navigates away from the typed
|
||||
// term; a keystroke still debouncing must not apply afterwards.
|
||||
this.destroyRef.onDestroy(
|
||||
inject(SettingsSearchService).onReveal(() =>
|
||||
this.cancelPendingSearchApply()
|
||||
)
|
||||
);
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user