mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
chore: resolve master conflicts for embedded mpv branch
Entire-Checkpoint: 5b514fe72836
This commit is contained in:
commit
ec2f6a702a
290 files changed
+12183
-4142
No files matched your search
@@ -0,0 +1,60 @@
|
||||
# Live EPG Panel Collapse
|
||||
|
||||
## Summary
|
||||
|
||||
Add a shared, persisted live-TV EPG collapse state across M3U, Xtream, and Stalker. Default remains expanded. When an internal player is active, the EPG can collapse to a slim bottom line showing the current program and an expand button; the player grows to use the freed space. External MPV/VLC playback keeps the existing full EPG-only layout.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add shared state helpers in `@iptvnator/portal/shared/util`:
|
||||
- storage key: `live-epg-panel-state`
|
||||
- states: `expanded | collapsed`
|
||||
- restore invalid/missing values to `expanded`
|
||||
- Add a shared `LiveEpgPanelComponent` in `shared-portals`:
|
||||
- inputs: `collapsed`, `summary`, `loading`
|
||||
- output: `collapsedChange`
|
||||
- keeps projected EPG content mounted while collapsed, but visually hides and inerts it so existing EPG effects still update current-program state.
|
||||
- Update the shared live layout SCSS mixin so `.epg.epg-collapsed` has a fixed slim height and `.video-player` fills the remaining space.
|
||||
- Wire the wrapper into:
|
||||
- M3U `VideoPlayerComponent`
|
||||
- Xtream `LiveStreamLayoutComponent`
|
||||
- Stalker `StalkerLiveStreamLayoutComponent`
|
||||
- legacy shared `LiveStreamLayoutComponent`, including ArtPlayer as an internal player.
|
||||
- Add i18n keys for collapse/expand/current-program labels to all locale JSON files, using English fallback where no translation is available.
|
||||
|
||||
## Behavior Rules
|
||||
|
||||
- Collapse is available only when an internal player is shown: Video.js, HTML5, or ArtPlayer.
|
||||
- Radio playback still hides EPG entirely.
|
||||
- External MPV/VLC playback does not render the collapse strip and keeps the full EPG panel.
|
||||
- The slim line shows current program title, start/end time, and progress when available; otherwise it shows the existing no-program/EPG-unavailable message.
|
||||
- The shared persisted state applies across all three live-TV modules.
|
||||
|
||||
## Tests
|
||||
|
||||
- Add unit coverage for restore/persist state validation.
|
||||
- Add shared `LiveEpgPanelComponent` tests for summary rendering, toggle output, hidden-but-mounted body, and fallback text.
|
||||
- Extend M3U, Xtream, and Stalker component specs for:
|
||||
- collapsed state from `localStorage`
|
||||
- toggle persistence
|
||||
- collapse only for internal players
|
||||
- external player keeps full EPG
|
||||
- current-program summary renders from each module's data source.
|
||||
- Run:
|
||||
- `pnpm nx test shared-portals`
|
||||
- `pnpm nx test ui-epg`
|
||||
- `pnpm nx test portal-xtream-feature`
|
||||
- `pnpm nx test portal-stalker-feature`
|
||||
- `pnpm nx lint playlist-m3u-feature-player`
|
||||
- `pnpm nx build web`
|
||||
- Verify in the running Electron app through CDP/agent-browser and Computer Use:
|
||||
- M3U, Xtream, Stalker live pages
|
||||
- Video.js, HTML5, ArtPlayer sizing
|
||||
- collapsed state survives reload/navigation
|
||||
- light and dark themes do not overlap or clip text.
|
||||
|
||||
## Docs And Plan Artifact
|
||||
|
||||
- Update `docs/architecture/iptvnator-ui-guidelines.md` with the collapsible live EPG pattern.
|
||||
- Update module docs where they describe active live EPG behavior, especially M3U and Stalker EPG docs.
|
||||
- If `IPTVNATOR_WIKI_VAULT` is configured after docs change, run `pnpm wiki:export --mode changed`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Unified Live EPG Toolbar
|
||||
|
||||
## Summary
|
||||
|
||||
Merge the current-program strip and date navigator into one shared `app-live-epg-panel` toolbar for M3U, Xtream, and Stalker live playback. The EPG list starts directly below the unified toolbar when the panel is expanded. In collapsed mode, keep the slim current-program strip and show a compact date chip.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Update `EpgListComponent` to support controlled date state:
|
||||
- Add `[selectedDate]`, `(selectedDateChange)`, and `[showDateNavigator]`.
|
||||
- Rename its internal date signal to avoid API collision.
|
||||
- Keep existing standalone behavior by default, so non-live or external-player layouts still show the internal date row.
|
||||
- Add small shared EPG date helpers in `@iptvnator/ui/epg`:
|
||||
- Today date key.
|
||||
- Parse date key.
|
||||
- Shift date key by previous/next day.
|
||||
- Extend `LiveEpgPanelComponent` with optional date navigation:
|
||||
- Inputs: `[showDateNavigator]`, `[selectedDate]`.
|
||||
- Output: `(dateNavigation)` emitting `prev | next`.
|
||||
- Expanded toolbar: collapse button, current-program label/title/time, previous-day button, date label, next-day button, progress line.
|
||||
- Collapsed toolbar: current-program summary plus compact date chip; the chip opens a small menu for previous/next day on narrow or collapsed states.
|
||||
- Wire the unified toolbar into M3U, Xtream, and Stalker live layouts.
|
||||
- Update focused unit specs and e2e selectors that currently assume `.selected-date` / `.next-day` live inside `app-epg-list`.
|
||||
- Update `docs/architecture/iptvnator-ui-guidelines.md` to document that live inline-player layouts use the unified current-program/date toolbar.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Run unit tests:
|
||||
- `pnpm nx test ui-epg`
|
||||
- `pnpm nx test shared-portals`
|
||||
- `pnpm nx test portal-xtream-feature`
|
||||
- `pnpm nx test portal-stalker-feature`
|
||||
- `pnpm nx test web`
|
||||
- Run build/type validation through Nx:
|
||||
- `pnpm nx build web --configuration=development`
|
||||
- Use the running Electron app via CDP `127.0.0.1:9222` with `agent-browser`:
|
||||
- Verify the IPTVnator page target is selected.
|
||||
- Confirm the old `app-epg-list #channel-header` date row is absent when wrapped by `app-live-epg-panel`.
|
||||
- Confirm the program list starts immediately below the unified toolbar.
|
||||
- Click previous/next day in the unified toolbar and verify visible EPG programs change.
|
||||
- Collapse the panel and verify the slim strip remains, body is inert, and the date chip stays visible.
|
||||
- Capture a screenshot for visual confirmation.
|
||||
- If docs changed and `IPTVNATOR_WIKI_VAULT` is configured, run `pnpm wiki:export --mode changed`.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Keep external MPV/VLC and non-inline layouts on the existing full EPG list behavior.
|
||||
- Use existing translation keys for current program, loading, no program info, previous day, and next day.
|
||||
- Leave unrelated dirty worktree changes untouched.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Fix Xtream Type-Level VOD/Series Search
|
||||
|
||||
## Summary
|
||||
|
||||
- Root cause: `withSelection()` receives the `q` query through the Xtream catalog facade, but `filteredAndSortedContent` returns `sortedContent()` immediately when `selectedCategoryId` is null, so `categorySearchTerm` is ignored on all-items VOD/series routes.
|
||||
- Fix the store selector so VOD and series search terms filter the current type even when no category is selected.
|
||||
- No public API, route, schema, or Electron IPC contract changes are needed.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts` so:
|
||||
- `vod` and `series` apply `filterBySearchTerm` whether `selectedCategoryId` is null or set.
|
||||
- Existing selected-category behavior, sorting, pagination reset, and `live` behavior stay unchanged.
|
||||
- Empty search terms still return the full sorted current type.
|
||||
- Update `libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts` with regression coverage for:
|
||||
- VOD all-items search with `selectedCategoryId === null`.
|
||||
- Series all-items search with `selectedCategoryId === null`, ideally across multiple categories.
|
||||
- Existing category-scoped search remains covered and unchanged.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Run `pnpm nx test portal-xtream-data-access`.
|
||||
- Run `pnpm nx test portal-xtream-feature` to confirm the shared catalog route/facade integration still passes.
|
||||
- Run `pnpm nx build web` as the Angular/Nx build validation.
|
||||
- Manual Electron check:
|
||||
- Start `pnpm run serve:backend`.
|
||||
- Load agent-browser guidance with `agent-browser skills get core` and `agent-browser skills get electron`.
|
||||
- Attach using `agent-browser --cdp 9222 snapshot -i`.
|
||||
- Verify an Xtream playlist can search from `/vod?q=...` and `/series?q=...` with no category selected, and still search inside a selected category.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Scope is limited to Xtream VOD/series catalog search, not the separate global search page.
|
||||
- Documentation updates are not expected because this is a regression fix for existing behavior; reassess after implementation.
|
||||
- Finalized plan file target: `.plans/2026-05-01-xtream-type-search.md`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Resolve Embedded MPV PR Merge Conflicts
|
||||
|
||||
## Summary
|
||||
|
||||
- Update `feat/embedded-mpv-mac-os-experimental` by merging `origin/master` into the branch, not rebasing, so no force-push is needed.
|
||||
- Resolve the four simulated conflicts while preserving both sides:
|
||||
- master’s settings refactor, cover-size setting, live EPG panel work, favorite/recent UX changes
|
||||
- branch’s macOS Embedded MPV support, player option gating, IPC typings, and package/runtime behavior
|
||||
- Leave untracked `tools/wiki/` untouched.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Settings: keep master’s refactored section components/options and reapply Embedded MPV support in that shape: `EmbeddedMpvSupport` signal, async support probe in `ngOnInit`, and conditional `VideoPlayer.EmbeddedMpv` option before external MPV/VLC.
|
||||
- Settings store/interfaces: combine `CoverSize`/`coverSize: 'medium'` with `VideoPlayer.EmbeddedMpv`, embedded MPV prepare/sanitize logic, and `isEmbeddedPlayer()` including Video.js, HTML5, ArtPlayer, and Embedded MPV.
|
||||
- Live playback conflicts: keep master’s `LiveEpgPanel`, date navigation, loading state, and favorite/recent outputs; add Embedded MPV rendering through `WebPlayerViewComponent` with full `ResolvedPortalPlayback` metadata where needed.
|
||||
- Electron typings/preload: keep master’s new EPG/current-program and recent-batch APIs while preserving all Embedded MPV IPC APIs and session update hooks.
|
||||
|
||||
## Public Interfaces
|
||||
|
||||
- Preserve existing branch additions:
|
||||
- `VideoPlayer.EmbeddedMpv = 'embedded-mpv'`
|
||||
- `EmbeddedMpvSupport`, `EmbeddedMpvSession`, `EmbeddedMpvBounds`
|
||||
- `window.electron` Embedded MPV support/session methods
|
||||
- Preserve master additions:
|
||||
- `CoverSize = 'small' | 'medium' | 'large'`
|
||||
- `Settings.coverSize`
|
||||
- `getCurrentProgramsBatch`
|
||||
- `dbRemoveRecentItemsBatch`
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Static checks:
|
||||
- `rg '<<<<<<<|=======|>>>>>>>'`
|
||||
- `git diff --check`
|
||||
- `pnpm run typecheck:ci`
|
||||
- Unit tests:
|
||||
- `pnpm run test:unit:all`
|
||||
- E2E tests:
|
||||
- `pnpm nx run web-e2e:e2e-ci`
|
||||
- `pnpm nx run electron-backend-e2e:e2e-ci`
|
||||
- Build/package confidence:
|
||||
- `pnpm run build:frontend`
|
||||
- `pnpm run build:backend`
|
||||
- run local package-layout validation if a package artifact is produced; otherwise rely on the pushed GitHub Actions macOS/Linux/Windows package jobs.
|
||||
- Docs/wiki:
|
||||
- Verify `docs/architecture/embedded-mpv-native.md` still matches the merged packaging/runtime behavior.
|
||||
- If repo docs changed and `IPTVNATOR_WIKI_VAULT` is set, run `pnpm wiki:export --mode changed`; otherwise report it as skipped.
|
||||
|
||||
## Commit And Push
|
||||
|
||||
- Stage only tracked merge-resolution files plus the saved `.plans/...` file; do not stage untracked `tools/wiki/`.
|
||||
- Commit as `chore: resolve master conflicts for embedded mpv branch`.
|
||||
- Push normally with `git push origin feat/embedded-mpv-mac-os-experimental`.
|
||||
- Recheck PR #877 merge state and checks with `gh pr view --json mergeStateStatus,statusCheckRollup`; note that Vercel was already an external failure unrelated to these conflicts.
|
||||
@@ -24,6 +24,19 @@ This file provides guidance to coding agents working in this repository.
|
||||
- The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder.
|
||||
- Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed.
|
||||
|
||||
## Regression Prevention And Test Updates
|
||||
|
||||
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
|
||||
- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed.
|
||||
- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite.
|
||||
- Default validation ladder:
|
||||
1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.
|
||||
2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
|
||||
3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.
|
||||
4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`.
|
||||
- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below.
|
||||
- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.
|
||||
|
||||
## Electron Debugging (CDP)
|
||||
|
||||
- Start the Electron development app with: `nx serve electron-backend`
|
||||
|
||||
@@ -24,6 +24,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- The wiki exporter only owns `_repo-context/` in the external vault. It must never overwrite repo docs or maintained wiki pages outside that folder.
|
||||
- Final task summaries should state whether docs were updated, which doc changed, and whether wiki export ran, was skipped, or failed.
|
||||
|
||||
## Regression Prevention And Test Updates
|
||||
|
||||
- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
|
||||
- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed.
|
||||
- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite.
|
||||
- Default validation ladder:
|
||||
1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.
|
||||
2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
|
||||
3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.
|
||||
4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`.
|
||||
- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below.
|
||||
- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.
|
||||
|
||||
## Project Overview
|
||||
|
||||
IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.
|
||||
@@ -139,20 +152,27 @@ npx --yes agent-browser --cdp 9222 tab list
|
||||
# Run frontend tests
|
||||
pnpm run test:frontend
|
||||
# or
|
||||
nx test web
|
||||
pnpm nx test web
|
||||
|
||||
# Run backend tests
|
||||
pnpm run test:backend
|
||||
# or
|
||||
nx test electron-backend
|
||||
pnpm nx test electron-backend
|
||||
|
||||
# Run e2e tests (Playwright)
|
||||
nx e2e web-e2e
|
||||
# Run targeted E2E tests (Playwright)
|
||||
pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts
|
||||
pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts
|
||||
|
||||
# Run tests with coverage
|
||||
nx test web --configuration=ci
|
||||
# Run broad E2E suites only when the impact justifies it
|
||||
pnpm nx e2e web-e2e
|
||||
pnpm nx e2e electron-backend-e2e
|
||||
|
||||
# Run tests with coverage when needed
|
||||
pnpm nx test web --configuration=ci
|
||||
```
|
||||
|
||||
Before finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary.
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
@@ -608,23 +628,25 @@ The factory pattern ensures a single codebase works in both environments without
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`
|
||||
- **E2E tests**: Playwright testing the web app
|
||||
- **E2E tests**: Playwright testing the web app and Electron app
|
||||
- Backend tests use standard Jest
|
||||
- Bug fixes should add focused regression coverage unless there is a documented reason not to.
|
||||
- Use the impact-based validation policy in `Regression Prevention And Test Updates` to choose targeted unit tests, atomized E2E targets, broad suites, or CDP/manual verification.
|
||||
|
||||
### Nx Commands
|
||||
|
||||
Use `nx` CLI for better performance:
|
||||
|
||||
```bash
|
||||
nx run <project>:<target>
|
||||
# Example: nx run web:build
|
||||
# Example: nx run electron-backend:serve
|
||||
pnpm nx run <project>:<target>
|
||||
# Example: pnpm nx run web:build
|
||||
# Example: pnpm nx run electron-backend:serve
|
||||
```
|
||||
|
||||
To run multiple projects:
|
||||
|
||||
```bash
|
||||
nx run-many --target=test --all
|
||||
pnpm nx run-many --target=test --all
|
||||
```
|
||||
|
||||
### Electron Build Process
|
||||
|
||||
@@ -276,6 +276,10 @@ $ pnpm run serve:frontend
|
||||
|
||||
**IPTVnator doesn't provide any playlists or other digital content.**
|
||||
|
||||
## Trademark
|
||||
|
||||
The name **"IPTVnator"** and the IPTVnator logo are unregistered trademarks of the project owner. The MIT license covers the source code only — it does **not** grant rights to the name or logo. Forks and redistributions (including app-store submissions) must use a different name and their own icon. See [TRADEMARK.md](./TRADEMARK.md) for details.
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
|
||||
[](#contributors)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Trademark Notice
|
||||
|
||||
**IPTVnator** (the name) and the IPTVnator logo / icon artwork are trademarks of the IPTVnator project owner ([4gray](https://github.com/4gray)). They are used as unregistered (common-law) trademarks to identify this project and its official builds.
|
||||
|
||||
## What the MIT License Covers
|
||||
|
||||
The [MIT License](./LICENSE.md) in this repository grants you broad rights to **use, copy, modify, and distribute the source code** of IPTVnator, including for commercial purposes.
|
||||
|
||||
## What the MIT License Does **Not** Cover
|
||||
|
||||
The MIT License grants rights to the **software**. It does **not** grant any rights to the project's branding. In particular, it does **not** grant you the right to:
|
||||
|
||||
- Use the name **"IPTVnator"** (or any confusingly similar variant such as "IPTV-Nator", "IPTVNator", "IPTV Nator", etc.) as the name of your fork, redistribution, app-store listing, paid product, or service.
|
||||
- Use the IPTVnator **logo, icon, or artwork** to identify your fork or any other product.
|
||||
- Imply, suggest, or otherwise lead users to believe that your fork or product is the official IPTVnator, is endorsed by the IPTVnator project, or is affiliated with it in any way.
|
||||
|
||||
This is consistent with how MIT-licensed projects such as Rust, Node.js, Mozilla, and Kubernetes treat their names and logos: the **code** is open, the **brand** is not.
|
||||
|
||||
## What You **Can** Do With a Fork
|
||||
|
||||
You are very welcome to fork and modify IPTVnator. When you redistribute it publicly (GitHub releases, app stores, paid SaaS, bundled hardware, etc.), please:
|
||||
|
||||
1. **Choose your own name** for the fork. It must not be "IPTVnator" or a confusingly similar variant.
|
||||
2. **Use your own logo / icon.** Do not reuse the IPTVnator logo, icon, or other artwork files from this repository as the identity of your fork.
|
||||
3. **Keep the MIT copyright notice** as the license requires.
|
||||
4. You may, factually, state that your project is "based on IPTVnator" or "a fork of IPTVnator" — this kind of nominative reference is fine and expected.
|
||||
|
||||
## Reporting Misuse
|
||||
|
||||
If you see a project, app store listing, or service that uses the IPTVnator name or logo in a way that violates this notice — especially on app stores where it can mislead users — please:
|
||||
|
||||
- Open an issue on this repository, **or**
|
||||
- Contact the project owner directly via the contact details on the [GitHub profile](https://github.com/4gray).
|
||||
|
||||
Apple App Store and Google Play both provide first-party channels for trademark and copycat-app disputes:
|
||||
|
||||
- Apple — [App Store Content Dispute](https://www.apple.com/legal/internet-services/itunes/appstorenotices/)
|
||||
- Google — [Removing Content From Google (Trademark)](https://support.google.com/legal/answer/3110420)
|
||||
|
||||
## Questions
|
||||
|
||||
If you are unsure whether a planned use of the IPTVnator name or logo is acceptable, please **ask before shipping**. Opening an issue is the easiest way to get a fast answer.
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
@@ -94,8 +95,15 @@ test.describe('Dashboard Activation', () => {
|
||||
/\/workspace\/xtreams\/[^/]+\/favorites$/
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('.player-toolbar .close-btn').first()
|
||||
app.mainWindow
|
||||
.locator(
|
||||
'app-unified-live-tab .content-container .video-player'
|
||||
)
|
||||
.first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, liveTitle).first()
|
||||
).toHaveClass(/(^|\s)active(\s|$)/);
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
|
||||
|
||||
@@ -73,21 +73,23 @@ test.describe('Electron Recently Viewed', () => {
|
||||
app.mainWindow,
|
||||
'Stable Recent Channel'
|
||||
).first();
|
||||
const closeButton = app.mainWindow
|
||||
.locator('.player-toolbar .close-btn')
|
||||
const livePlayer = app.mainWindow
|
||||
.locator('app-unified-live-tab .content-container .video-player')
|
||||
.first();
|
||||
|
||||
await item.click();
|
||||
await expect(closeButton).toBeVisible({ timeout: 20000 });
|
||||
await expect(livePlayer).toBeVisible({ timeout: 20000 });
|
||||
await expect(item).toHaveClass(/(^|\s)active(\s|$)/);
|
||||
|
||||
await item.click();
|
||||
await expect(closeButton).toBeVisible({ timeout: 20000 });
|
||||
await expect(livePlayer).toBeVisible({ timeout: 20000 });
|
||||
await expect(item).toHaveClass(/(^|\s)active(\s|$)/);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({
|
||||
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const playlistTitle = 'm3u-recent-source.m3u';
|
||||
@@ -127,10 +129,15 @@ test.describe('Electron Recently Viewed', () => {
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel Two', 'Recent Channel One']);
|
||||
|
||||
await removeLiveRecentItem(app.mainWindow, 'Recent Channel Two');
|
||||
await toggleFavoriteForChannel(app.mainWindow, 'Recent Channel Two');
|
||||
await expect
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel One']);
|
||||
.toEqual(['Recent Channel Two', 'Recent Channel One']);
|
||||
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'Recent Channel Two').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
@@ -145,7 +152,12 @@ test.describe('Electron Recently Viewed', () => {
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
await expect
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel One']);
|
||||
.toEqual(['Recent Channel Two', 'Recent Channel One']);
|
||||
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'Recent Channel Two').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
@@ -189,7 +201,7 @@ test.describe('Electron Recently Viewed', () => {
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
|
||||
await closeUnifiedLiveDetail(app.mainWindow);
|
||||
await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
@@ -396,7 +408,7 @@ test.describe('Electron Recently Viewed', () => {
|
||||
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
|
||||
await closeUnifiedLiveDetail(app.mainWindow);
|
||||
await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle);
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
@@ -561,11 +573,18 @@ async function clearRecentItems(page: Page, typeLabel: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Yes' }).click();
|
||||
}
|
||||
|
||||
async function closeUnifiedLiveDetail(page: Page): Promise<void> {
|
||||
const closeButton = page.locator('.player-toolbar .close-btn').first();
|
||||
|
||||
await expect(closeButton).toBeVisible({ timeout: 20000 });
|
||||
await closeButton.click();
|
||||
async function expectUnifiedLiveDetailOpen(
|
||||
page: Page,
|
||||
title: string
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.locator('app-unified-live-tab .content-container .video-player')
|
||||
.first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(channelItemByTitle(page, title).first()).toHaveClass(
|
||||
/(^|\s)active(\s|$)/
|
||||
);
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
@@ -634,14 +653,6 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
await episodeCard.click();
|
||||
}
|
||||
|
||||
async function removeLiveRecentItem(page: Page, title: string): Promise<void> {
|
||||
const item = channelItemByTitle(page, title).first();
|
||||
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
}
|
||||
|
||||
async function toggleFavoriteForChannel(
|
||||
page: Page,
|
||||
title: string
|
||||
@@ -651,6 +662,9 @@ async function toggleFavoriteForChannel(
|
||||
await expect(item).toBeVisible({ timeout: 20000 });
|
||||
await item.hover();
|
||||
await item.locator('.favorite-button').first().click();
|
||||
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
|
||||
'star'
|
||||
);
|
||||
}
|
||||
|
||||
async function visibleLiveTitles(page: Page): Promise<string[]> {
|
||||
|
||||
@@ -31,7 +31,9 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const currentProgram = fixture.shortEpg[0];
|
||||
if (!currentProgram) {
|
||||
throw new Error('Expected the Xtream EPG fixture to include a current program.');
|
||||
throw new Error(
|
||||
'Expected the Xtream EPG fixture to include a current program.'
|
||||
);
|
||||
}
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: { TZ: timeZone },
|
||||
@@ -45,7 +47,10 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
fixture.categoryName
|
||||
);
|
||||
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
@@ -55,14 +60,18 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
((await channelRow.locator('.epg-title').textContent()) ?? '').trim()
|
||||
(
|
||||
(await channelRow
|
||||
.locator('.epg-title')
|
||||
.textContent()) ?? ''
|
||||
).trim()
|
||||
)
|
||||
.toBe(currentProgram.title);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return (await channelRow.locator('.epg-time').allInnerTexts()).map(
|
||||
(value) => value.trim()
|
||||
);
|
||||
return (
|
||||
await channelRow.locator('.epg-time').allInnerTexts()
|
||||
).map((value) => value.trim());
|
||||
})
|
||||
.toEqual([
|
||||
formatTimeInZone(currentProgram.startTimestamp, timeZone),
|
||||
@@ -84,22 +93,14 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
timeZone
|
||||
);
|
||||
|
||||
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
|
||||
currentDayTitles
|
||||
);
|
||||
await expect
|
||||
.poll(() => visibleProgramTitles(app.mainWindow))
|
||||
.toEqual(currentDayTitles);
|
||||
|
||||
const currentProgramRow = app.mainWindow
|
||||
.locator('app-epg-list .program-item.current-program')
|
||||
.first();
|
||||
await expect(currentProgramRow).toBeVisible();
|
||||
await expect(currentProgramRow.locator('.program-title')).toHaveText(
|
||||
currentProgram.title
|
||||
);
|
||||
await expect(currentProgramRow.locator('.time')).toHaveText(
|
||||
`${formatTimeInZone(
|
||||
currentProgram.startTimestamp,
|
||||
timeZone
|
||||
)} - ${formatTimeInZone(currentProgram.stopTimestamp, timeZone)}`
|
||||
await expectCurrentProgram(
|
||||
app.mainWindow,
|
||||
currentProgram,
|
||||
timeZone
|
||||
);
|
||||
|
||||
const firstFutureDateKey = getFirstFutureDateKey(
|
||||
@@ -114,25 +115,29 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
firstFutureDateKey!,
|
||||
timeZone
|
||||
);
|
||||
const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!);
|
||||
const nextDayClicks = dayDifference(
|
||||
currentDateKey,
|
||||
firstFutureDateKey!
|
||||
);
|
||||
|
||||
for (let index = 0; index < nextDayClicks; index += 1) {
|
||||
await app.mainWindow.locator('app-epg-list .next-day').click();
|
||||
await app.mainWindow
|
||||
.locator(
|
||||
'app-live-epg-panel .next-day, app-epg-list .next-day'
|
||||
)
|
||||
.click();
|
||||
}
|
||||
|
||||
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
|
||||
futureDayTitles
|
||||
);
|
||||
await expect
|
||||
.poll(() => visibleProgramTitles(app.mainWindow))
|
||||
.toEqual(futureDayTitles);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimeInZone(
|
||||
timestampSeconds: number,
|
||||
timeZone: string
|
||||
): string {
|
||||
function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hour: '2-digit',
|
||||
@@ -157,7 +162,9 @@ function formatDateKeyInZone(
|
||||
const day = parts.find((part) => part.type === 'day')?.value;
|
||||
|
||||
if (!year || !month || !day) {
|
||||
throw new Error(`Failed to format a date key for timezone ${timeZone}.`);
|
||||
throw new Error(
|
||||
`Failed to format a date key for timezone ${timeZone}.`
|
||||
);
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
@@ -215,14 +222,48 @@ function dayDifference(fromDateKey: string, toDateKey: string): number {
|
||||
return Math.round((to - from) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
async function visibleProgramTitles(page: Parameters<typeof channelItemByTitle>[0]) {
|
||||
async function visibleProgramTitles(
|
||||
page: Parameters<typeof channelItemByTitle>[0]
|
||||
) {
|
||||
return page
|
||||
.locator('app-epg-list .program-title')
|
||||
.allInnerTexts()
|
||||
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
async function getProgressWidthPercent(row: ReturnType<typeof channelItemByTitle>) {
|
||||
async function expectCurrentProgram(
|
||||
page: Parameters<typeof channelItemByTitle>[0],
|
||||
currentProgram: XtreamNormalizedEpgListing,
|
||||
timeZone: string
|
||||
): Promise<void> {
|
||||
const startTime = formatTimeInZone(currentProgram.startTimestamp, timeZone);
|
||||
const stopTime = formatTimeInZone(currentProgram.stopTimestamp, timeZone);
|
||||
const currentProgramRow = page
|
||||
.locator('app-epg-list .program-item.current-program')
|
||||
.first();
|
||||
|
||||
if (await currentProgramRow.isVisible().catch(() => false)) {
|
||||
await expect(currentProgramRow.locator('.program-title')).toHaveText(
|
||||
currentProgram.title
|
||||
);
|
||||
await expect(currentProgramRow.locator('.time')).toHaveText(
|
||||
`${startTime}–${stopTime}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = page.locator('app-live-epg-panel').first();
|
||||
await expect(summary.locator('.live-epg-panel__title')).toHaveText(
|
||||
currentProgram.title
|
||||
);
|
||||
await expect(summary.locator('.live-epg-panel__time')).toHaveText(
|
||||
`${startTime} - ${stopTime}`
|
||||
);
|
||||
}
|
||||
|
||||
async function getProgressWidthPercent(
|
||||
row: ReturnType<typeof channelItemByTitle>
|
||||
) {
|
||||
const style = await row.locator('.epg-progress-fill').getAttribute('style');
|
||||
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
|
||||
return Number.parseFloat(width);
|
||||
|
||||
@@ -402,6 +402,8 @@ const electronApi = {
|
||||
ipcRenderer.invoke('FETCH_EPG', { url: urls }),
|
||||
getChannelPrograms: (channelId: string) =>
|
||||
ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId }),
|
||||
getCurrentProgramsBatch: (channelIds: string[]) =>
|
||||
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', { channelIds }),
|
||||
getEpgChannelMetadata: (channelIds: string[]) =>
|
||||
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds }),
|
||||
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
|
||||
@@ -604,6 +606,9 @@ const electronApi = {
|
||||
ipcRenderer.invoke('DB_CLEAR_PLAYLIST_RECENT_ITEMS', playlistId),
|
||||
dbRemoveRecentItem: (contentId: number, playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_REMOVE_RECENT_ITEM', contentId, playlistId),
|
||||
dbRemoveRecentItemsBatch: (
|
||||
items: { contentId: number; playlistId: string }[]
|
||||
) => ipcRenderer.invoke('DB_REMOVE_RECENT_ITEMS_BATCH', items),
|
||||
dbGetContentByXtreamId: (
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { __databaseConnectionTestHooks } from 'database';
|
||||
|
||||
function statementContaining(pattern: string): string {
|
||||
const statement =
|
||||
__databaseConnectionTestHooks.createTableStatements.find((entry) =>
|
||||
entry.includes(pattern)
|
||||
) ??
|
||||
__databaseConnectionTestHooks.indexMigrationStatements.find((entry) =>
|
||||
entry.includes(pattern)
|
||||
) ??
|
||||
__databaseConnectionTestHooks.columnMigrationStatements.find((entry) =>
|
||||
entry.includes(pattern)
|
||||
);
|
||||
|
||||
if (!statement) {
|
||||
throw new Error(`Expected database statement containing "${pattern}"`);
|
||||
}
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
describe('database connection schema', () => {
|
||||
it('creates fresh Xtream cache tables with the unique targets used by IPC writes', () => {
|
||||
expect(statementContaining('CREATE TABLE IF NOT EXISTS categories')).toContain(
|
||||
'UNIQUE(playlist_id, type, xtream_id)'
|
||||
);
|
||||
expect(statementContaining('CREATE TABLE IF NOT EXISTS content')).toContain(
|
||||
'UNIQUE(category_id, type, xtream_id)'
|
||||
);
|
||||
});
|
||||
|
||||
it('creates the favorites position index only after the position migration can run', () => {
|
||||
expect(statementContaining('CREATE TABLE IF NOT EXISTS favorites')).toContain(
|
||||
'position INTEGER DEFAULT 0'
|
||||
);
|
||||
expect(
|
||||
__databaseConnectionTestHooks.createTableStatements.some((entry) =>
|
||||
entry.includes('favorites_playlist_position_idx')
|
||||
)
|
||||
).toBe(false);
|
||||
expect(statementContaining('ALTER TABLE favorites ADD COLUMN position')).toBe(
|
||||
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0'
|
||||
);
|
||||
expect(statementContaining('favorites_playlist_position_idx')).toContain(
|
||||
'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ jest.mock('drizzle-orm', () => ({
|
||||
|
||||
import * as schema from 'database-schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
import { getContentByXtreamId } from './content.operations';
|
||||
import { getContentByXtreamId, saveContent } from './content.operations';
|
||||
|
||||
function createDbMock(result: unknown[] = []) {
|
||||
const limit = jest.fn().mockResolvedValue(result);
|
||||
@@ -91,4 +91,79 @@ describe('content.operations', () => {
|
||||
expect(eqMock).not.toHaveBeenCalledWith(schema.content.type, 'series');
|
||||
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses a synchronous better-sqlite transaction callback when saving content', async () => {
|
||||
const existingContentWhere = jest.fn().mockResolvedValue([{ count: 0 }]);
|
||||
const existingContentInnerJoin = jest
|
||||
.fn()
|
||||
.mockReturnValue({ where: existingContentWhere });
|
||||
const existingContentFrom = jest
|
||||
.fn()
|
||||
.mockReturnValue({ innerJoin: existingContentInnerJoin });
|
||||
|
||||
const categoriesWhere = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 42,
|
||||
xtreamId: 201,
|
||||
},
|
||||
]);
|
||||
const categoriesFrom = jest
|
||||
.fn()
|
||||
.mockReturnValue({ where: categoriesWhere });
|
||||
|
||||
const select = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({ from: existingContentFrom })
|
||||
.mockReturnValueOnce({ from: categoriesFrom });
|
||||
|
||||
const run = jest.fn();
|
||||
const onConflictDoNothing = jest.fn().mockReturnValue({ run });
|
||||
const values = jest.fn().mockReturnValue({ onConflictDoNothing });
|
||||
const insert = jest.fn().mockReturnValue({ values });
|
||||
const transactionResults: unknown[] = [];
|
||||
const transaction = jest.fn((callback: (tx: unknown) => unknown) => {
|
||||
const result = callback({ insert });
|
||||
transactionResults.push(result);
|
||||
|
||||
if (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'then' in result
|
||||
) {
|
||||
throw new Error('Transaction function cannot return a promise');
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
const db = {
|
||||
select,
|
||||
transaction,
|
||||
} as unknown as AppDatabase;
|
||||
|
||||
await expect(
|
||||
saveContent(
|
||||
db,
|
||||
'playlist-1',
|
||||
[
|
||||
{
|
||||
category_id: '201',
|
||||
name: 'News Live',
|
||||
stream_id: '100',
|
||||
},
|
||||
],
|
||||
'live'
|
||||
)
|
||||
).resolves.toEqual({ success: true, count: 1 });
|
||||
|
||||
expect(transactionResults).toEqual([undefined]);
|
||||
expect(values).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
categoryId: 42,
|
||||
title: 'News Live',
|
||||
xtreamId: 100,
|
||||
type: 'live',
|
||||
}),
|
||||
]);
|
||||
expect(run).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -311,16 +311,19 @@ export async function saveContent(
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
await checkpointOperation(control);
|
||||
const chunk = values.slice(index, index + chunkSize);
|
||||
await db
|
||||
.insert(schema.content)
|
||||
.values(chunk)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.content.categoryId,
|
||||
schema.content.type,
|
||||
schema.content.xtreamId,
|
||||
],
|
||||
});
|
||||
await db.transaction((tx) => {
|
||||
tx
|
||||
.insert(schema.content)
|
||||
.values(chunk)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.content.categoryId,
|
||||
schema.content.type,
|
||||
schema.content.xtreamId,
|
||||
],
|
||||
})
|
||||
.run();
|
||||
});
|
||||
totalInserted += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'saving-content',
|
||||
@@ -365,13 +368,21 @@ export async function clearXtreamImportCache(
|
||||
contentRows.map((row) => row.id),
|
||||
100
|
||||
)) {
|
||||
await db.delete(schema.content).where(inArray(schema.content.id, chunk));
|
||||
await db.transaction((tx) => {
|
||||
tx
|
||||
.delete(schema.content)
|
||||
.where(inArray(schema.content.id, chunk))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
for (const chunk of chunkValues(categoryIds, 100)) {
|
||||
await db
|
||||
.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk));
|
||||
await db.transaction((tx) => {
|
||||
tx
|
||||
.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk))
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -168,15 +168,25 @@ export async function reorderGlobalFavorites(
|
||||
let current = 0;
|
||||
const total = updates.length;
|
||||
|
||||
// Drizzle's .set() doesn't accept a bare Placeholder — wrap it in an
|
||||
// sql template so the value resolves to SQL<number> at compile time.
|
||||
const updateFavoritePosition = db
|
||||
.update(schema.favorites)
|
||||
.set({ position: sql<number>`${sql.placeholder('position')}` })
|
||||
.where(eq(schema.favorites.contentId, sql.placeholder('contentId')))
|
||||
.prepare();
|
||||
|
||||
for (const chunk of chunkValues(updates, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
|
||||
for (const { content_id, position } of chunk) {
|
||||
await db
|
||||
.update(schema.favorites)
|
||||
.set({ position })
|
||||
.where(eq(schema.favorites.contentId, content_id));
|
||||
}
|
||||
await db.transaction(() => {
|
||||
for (const { content_id, position } of chunk) {
|
||||
updateFavoritePosition.execute({
|
||||
position,
|
||||
contentId: content_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
current += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
|
||||
@@ -248,26 +248,28 @@ export async function upsertAppPlaylists(
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
const rows = playlists
|
||||
.map((playlist) => buildPlaylistRow(playlist))
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
|
||||
for (const playlist of playlists) {
|
||||
const row = buildPlaylistRow(playlist);
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(schema.playlists)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
});
|
||||
|
||||
count += 1;
|
||||
if (rows.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
return { success: true, count };
|
||||
await db.transaction((tx) => {
|
||||
for (const row of rows) {
|
||||
tx
|
||||
.insert(schema.playlists)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, count: rows.length };
|
||||
}
|
||||
|
||||
export async function getAppPlaylists(db: AppDatabase) {
|
||||
@@ -395,7 +397,9 @@ export async function deletePlaylist(
|
||||
|
||||
for (const chunk of chunkValues(ids, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db.delete(table).where(inArray(column, chunk));
|
||||
await db.transaction((tx) => {
|
||||
tx.delete(table).where(inArray(column, chunk)).run();
|
||||
});
|
||||
current += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase,
|
||||
|
||||
@@ -150,3 +150,36 @@ export async function removeRecentItem(
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function removeRecentItemsBatch(
|
||||
db: AppDatabase,
|
||||
items: { contentId: number; playlistId: string }[]
|
||||
): Promise<{ success: boolean; count: number }> {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
const stmt = db
|
||||
.delete(schema.recentlyViewed)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
schema.recentlyViewed.contentId,
|
||||
sql.placeholder('contentId')
|
||||
),
|
||||
eq(
|
||||
schema.recentlyViewed.playlistId,
|
||||
sql.placeholder('playlistId')
|
||||
)
|
||||
)
|
||||
)
|
||||
.prepare();
|
||||
|
||||
await db.transaction(() => {
|
||||
for (const { contentId, playlistId } of items) {
|
||||
stmt.execute({ contentId, playlistId });
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, count: items.length };
|
||||
}
|
||||
@@ -123,9 +123,12 @@ export async function deleteXtreamContent(
|
||||
DEFAULT_BATCH_SIZE
|
||||
)) {
|
||||
await checkpointOperation(control);
|
||||
await db
|
||||
.delete(schema.content)
|
||||
.where(inArray(schema.content.id, chunk));
|
||||
await db.transaction((tx) => {
|
||||
tx
|
||||
.delete(schema.content)
|
||||
.where(inArray(schema.content.id, chunk))
|
||||
.run();
|
||||
});
|
||||
deletedContent += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-content',
|
||||
@@ -141,9 +144,12 @@ export async function deleteXtreamContent(
|
||||
|
||||
for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db
|
||||
.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk));
|
||||
await db.transaction((tx) => {
|
||||
tx
|
||||
.delete(schema.categories)
|
||||
.where(inArray(schema.categories.id, chunk))
|
||||
.run();
|
||||
});
|
||||
deletedCategories += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'deleting-categories',
|
||||
@@ -264,7 +270,9 @@ export async function restoreXtreamUserData(
|
||||
|
||||
for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db.insert(schema.favorites).values(chunk);
|
||||
await db.transaction((tx) => {
|
||||
tx.insert(schema.favorites).values(chunk).run();
|
||||
});
|
||||
restoredFavorites += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'restoring-favorites',
|
||||
@@ -305,7 +313,9 @@ export async function restoreXtreamUserData(
|
||||
|
||||
for (const chunk of chunkValues(recentlyViewedValues, DEFAULT_BATCH_SIZE)) {
|
||||
await checkpointOperation(control);
|
||||
await db.insert(schema.recentlyViewed).values(chunk);
|
||||
await db.transaction((tx) => {
|
||||
tx.insert(schema.recentlyViewed).values(chunk).run();
|
||||
});
|
||||
restoredRecentlyViewed += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'restoring-recently-viewed',
|
||||
|
||||
@@ -35,3 +35,10 @@ handleWorkerRequest(
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
|
||||
handleWorkerRequest(
|
||||
'DB_REMOVE_RECENT_ITEMS_BATCH',
|
||||
(items: { contentId: number; playlistId: string }[]) => ({
|
||||
items,
|
||||
})
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { and, eq, gte, inArray, lte, sql } from 'drizzle-orm';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { EpgChannelMetadata, EpgProgram } from 'shared-interfaces';
|
||||
@@ -81,6 +81,14 @@ export default class EpgEvents {
|
||||
}
|
||||
);
|
||||
|
||||
// Get current programs for many channels in a single batched query
|
||||
ipcMain.handle(
|
||||
'GET_CURRENT_PROGRAMS_BATCH',
|
||||
async (_event, args: { channelIds: string[] }) => {
|
||||
return this.handleGetCurrentProgramsBatch(args.channelIds);
|
||||
}
|
||||
);
|
||||
|
||||
// Get all channels from database
|
||||
ipcMain.handle('EPG_GET_CHANNELS', async () => {
|
||||
return this.handleGetAllChannels();
|
||||
@@ -635,6 +643,115 @@ export default class EpgEvents {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch lookup of "currently playing" programs for many channels in a
|
||||
* single SQL query. Replaces the N+1 pattern where the channel list
|
||||
* fired one IPC + query per visible channel.
|
||||
*/
|
||||
private static async handleGetCurrentProgramsBatch(
|
||||
channelIds: string[]
|
||||
): Promise<Record<string, EpgProgram | null>> {
|
||||
const result: Record<string, EpgProgram | null> = {};
|
||||
if (!Array.isArray(channelIds) || channelIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const validIds = Array.from(
|
||||
new Set(
|
||||
channelIds
|
||||
.map((id) => id?.trim())
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
);
|
||||
if (validIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.epgPrograms.channelId, validIds),
|
||||
lte(schema.epgPrograms.start, now),
|
||||
gte(schema.epgPrograms.stop, now)
|
||||
)
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
if (!result[row.channelId]) {
|
||||
const program = this.transformDbRowToEpgProgram(row);
|
||||
if (this.isValidEpgProgram(program)) {
|
||||
result[row.channelId] = program;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-channel fallback for IDs that didn't match by exact channel_id.
|
||||
// Mirrors handleGetChannelPrograms's NOCASE-id and display-name resolution.
|
||||
const unmatchedIds = validIds.filter((id) => !(id in result));
|
||||
for (const channelId of unmatchedIds) {
|
||||
result[channelId] = null;
|
||||
|
||||
let channel = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
sql`${schema.epgChannels.id} = ${channelId} COLLATE NOCASE`
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (channel.length === 0) {
|
||||
channel = await db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
sql`${schema.epgChannels.displayName} = ${channelId} COLLATE NOCASE`
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (channel.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const programRows = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.epgPrograms.channelId, channel[0].id),
|
||||
lte(schema.epgPrograms.start, now),
|
||||
gte(schema.epgPrograms.stop, now)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (programRows.length > 0) {
|
||||
const program = this.transformDbRowToEpgProgram(
|
||||
programRows[0]
|
||||
);
|
||||
if (this.isValidEpgProgram(program)) {
|
||||
result[channelId] = program;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
this.loggerLabel,
|
||||
'Error getting batch current programs:',
|
||||
error
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all channels from database
|
||||
*/
|
||||
|
||||
@@ -39,6 +39,7 @@ export const DB_WORKER_OPERATIONS = [
|
||||
'DB_ADD_RECENT_ITEM',
|
||||
'DB_CLEAR_PLAYLIST_RECENT_ITEMS',
|
||||
'DB_REMOVE_RECENT_ITEM',
|
||||
'DB_REMOVE_RECENT_ITEMS_BATCH',
|
||||
'DB_SAVE_PLAYBACK_POSITION',
|
||||
'DB_GET_PLAYBACK_POSITION',
|
||||
'DB_GET_SERIES_PLAYBACK_POSITIONS',
|
||||
|
||||
@@ -80,6 +80,10 @@ export async function getWorkerDatabase(): Promise<AppDatabase> {
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('busy_timeout = 5000');
|
||||
sqlite.pragma('synchronous = NORMAL');
|
||||
sqlite.pragma('cache_size = -64000');
|
||||
sqlite.pragma('temp_store = MEMORY');
|
||||
sqlite.pragma('mmap_size = 268435456');
|
||||
|
||||
if (isSqlTraceEnabled()) {
|
||||
trace('sql-worker', 'open', {
|
||||
@@ -96,6 +100,12 @@ export function closeWorkerDatabase(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.pragma('optimize');
|
||||
} catch {
|
||||
// Optimize is advisory; never block close on it.
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
|
||||
if (isSqlTraceEnabled()) {
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
getRecentItems,
|
||||
getRecentlyViewed,
|
||||
removeRecentItem,
|
||||
removeRecentItemsBatch,
|
||||
} from '../database/operations/recently-viewed.operations';
|
||||
import {
|
||||
deleteXtreamContent,
|
||||
@@ -761,6 +762,13 @@ async function executeRequest(message: DbWorkerRequestMessage) {
|
||||
return removeRecentItem(db, payload.contentId, payload.playlistId);
|
||||
}
|
||||
|
||||
case 'DB_REMOVE_RECENT_ITEMS_BATCH': {
|
||||
const payload = message.payload as {
|
||||
items: { contentId: number; playlistId: string }[];
|
||||
};
|
||||
return removeRecentItemsBatch(db, payload.items);
|
||||
}
|
||||
|
||||
case 'DB_SAVE_PLAYBACK_POSITION': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
|
||||
@@ -201,9 +201,11 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => {
|
||||
await expect(page.locator('app-epg-list')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator('app-epg-list .selected-date')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator(
|
||||
'app-live-epg-panel .selected-date, app-epg-list .selected-date'
|
||||
)
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
test('@stalker bulk EPG is fetched once and reused across channel switches', async ({
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Cover size tokens — consumed by all cover/poster grids and rails.
|
||||
// Toggled via [data-cover-size] attribute on <html>, set from SettingsStore.
|
||||
:root {
|
||||
--cover-grid-min-width: 148px;
|
||||
--cover-rail-width: 172px;
|
||||
--cover-gap: 16px;
|
||||
}
|
||||
|
||||
:root[data-cover-size='small'] {
|
||||
--cover-grid-min-width: 120px;
|
||||
--cover-rail-width: 140px;
|
||||
--cover-gap: 12px;
|
||||
}
|
||||
|
||||
:root[data-cover-size='medium'] {
|
||||
--cover-grid-min-width: 148px;
|
||||
--cover-rail-width: 172px;
|
||||
--cover-gap: 16px;
|
||||
}
|
||||
|
||||
:root[data-cover-size='large'] {
|
||||
--cover-grid-min-width: 200px;
|
||||
--cover-rail-width: 220px;
|
||||
--cover-gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:root[data-cover-size='large'] {
|
||||
--cover-grid-min-width: 160px;
|
||||
--cover-rail-width: 170px;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, HostBinding, inject, OnInit } from '@angular/core';
|
||||
import { Component, effect, HostBinding, inject, OnInit } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router, RouterOutlet } from '@angular/router';
|
||||
import { Actions, ofType } from '@ngrx/effects';
|
||||
@@ -9,7 +9,7 @@ import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
|
||||
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { filter, take } from 'rxjs';
|
||||
import { DataService } from 'services';
|
||||
import { DataService, SettingsStore } from 'services';
|
||||
import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
Language,
|
||||
@@ -39,6 +39,7 @@ export class AppComponent implements OnInit {
|
||||
private store = inject(Store);
|
||||
private translate = inject(TranslateService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
|
||||
/** Default language as fallback */
|
||||
@@ -64,6 +65,11 @@ export class AppComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
}
|
||||
effect(() => {
|
||||
const size = this.settingsStore.coverSize?.() ?? 'medium';
|
||||
document.documentElement.dataset.coverSize = size;
|
||||
});
|
||||
|
||||
if (window.electron) {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
|
||||
@@ -69,18 +69,12 @@ export const appConfig: ApplicationConfig = {
|
||||
}),
|
||||
provideEffects([PlaylistEffects]),
|
||||
provideRouterStore(),
|
||||
provideStoreDevtools({
|
||||
maxAge: 25,
|
||||
logOnly: AppConfig.production,
|
||||
}),
|
||||
...(AppConfig.production ? [] : [provideStoreDevtools({ maxAge: 25 })]),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: AppConfig.production && !!window.electron,
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
}),
|
||||
importProvidersFrom(
|
||||
AppConfig.environment === 'WEB'
|
||||
? NgxIndexedDBModule.forRoot(dbConfig)
|
||||
: [],
|
||||
NgxIndexedDBModule.forRoot(dbConfig),
|
||||
NgxSkeletonLoaderModule.forRoot({
|
||||
animation: 'pulse',
|
||||
|
||||
@@ -2,6 +2,7 @@ describe('app routes', () => {
|
||||
let workspaceChildren: Array<{
|
||||
canActivate?: unknown[];
|
||||
data?: Record<string, unknown>;
|
||||
loadChildren?: unknown;
|
||||
loadComponent?: unknown;
|
||||
path?: string;
|
||||
redirectTo?: unknown;
|
||||
@@ -9,25 +10,61 @@ describe('app routes', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.resetModules();
|
||||
jest.unstable_mockModule('@iptvnator/playlist/m3u/feature-player', () => ({
|
||||
M3uCollectionRouteComponent: class {},
|
||||
VideoPlayerComponent: class {},
|
||||
provideM3uWorkspaceRouteSession: () => [],
|
||||
}));
|
||||
jest.unstable_mockModule(
|
||||
'@iptvnator/playlist/m3u/feature-player',
|
||||
() => ({
|
||||
createM3uWorkspaceRoutes: () => [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'all',
|
||||
},
|
||||
{
|
||||
path: 'favorites',
|
||||
data: {
|
||||
mode: 'favorites',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'recent',
|
||||
data: {
|
||||
mode: 'recent',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':view',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { routes } = await import('./app.routes');
|
||||
const workspaceRoute = routes.find((route) => route.path === 'workspace');
|
||||
const workspaceRoute = routes.find(
|
||||
(route) => route.path === 'workspace'
|
||||
);
|
||||
workspaceChildren = workspaceRoute?.children ?? [];
|
||||
});
|
||||
|
||||
it('routes M3U favorites and recent pages through the shared collection wrapper', async () => {
|
||||
const favoritesRoute = workspaceChildren.find(
|
||||
(route) => route.path === 'playlists/:id/favorites'
|
||||
it('lazy-loads M3U workspace routes through the feature boundary', async () => {
|
||||
const playlistRoute = workspaceChildren.find(
|
||||
(route) => route.path === 'playlists/:id'
|
||||
);
|
||||
const recentRoute = workspaceChildren.find(
|
||||
(route) => route.path === 'playlists/:id/recent'
|
||||
const loadChildren = playlistRoute?.loadChildren as
|
||||
| (() => Promise<typeof workspaceChildren>)
|
||||
| undefined;
|
||||
const m3uRoutes = (await loadChildren?.()) ?? [];
|
||||
const defaultRoute = m3uRoutes.find((route) => route.path === '');
|
||||
const favoritesRoute = m3uRoutes.find(
|
||||
(route) => route.path === 'favorites'
|
||||
);
|
||||
const recentRoute = m3uRoutes.find((route) => route.path === 'recent');
|
||||
const playerRoute = m3uRoutes.find((route) => route.path === ':view');
|
||||
|
||||
expect(defaultRoute?.redirectTo).toBe('all');
|
||||
expect(favoritesRoute?.data).toEqual({
|
||||
mode: 'favorites',
|
||||
portalType: 'm3u',
|
||||
@@ -39,8 +76,7 @@ describe('app routes', () => {
|
||||
defaultScope: 'playlist',
|
||||
});
|
||||
|
||||
expect(typeof favoritesRoute?.loadComponent).toBe('function');
|
||||
expect(typeof recentRoute?.loadComponent).toBe('function');
|
||||
expect(playerRoute).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds a shared /workspace/global-recent route', async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
import { provideM3uWorkspaceRouteSession } from '@iptvnator/playlist/m3u/feature-player';
|
||||
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
const workspaceEntryRedirect = async () =>
|
||||
@@ -54,41 +53,9 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'playlists/:id/all',
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id/favorites',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
loadComponent: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then(
|
||||
(c) => c.M3uCollectionRouteComponent
|
||||
),
|
||||
data: {
|
||||
mode: 'favorites',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id/recent',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
loadComponent: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then(
|
||||
(c) => c.M3uCollectionRouteComponent
|
||||
),
|
||||
data: {
|
||||
mode: 'recent',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id/:view',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
loadComponent: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then(
|
||||
(c) => c.VideoPlayerComponent
|
||||
loadChildren: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then((m) =>
|
||||
m.createM3uWorkspaceRoutes()
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EnvironmentInjector,
|
||||
OnDestroy,
|
||||
Type,
|
||||
ViewContainerRef,
|
||||
effect,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -14,14 +21,24 @@ import {
|
||||
UnifiedCollectionDetailDirective,
|
||||
UnifiedCollectionPageComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import { CollectionScope, PortalProvider } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerCollectionDetailComponent } from '@iptvnator/portal/stalker/feature';
|
||||
import { XtreamCollectionDetailComponent } from '@iptvnator/portal/xtream/feature';
|
||||
import {
|
||||
CollectionScope,
|
||||
PortalProvider,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
type UnifiedPortalType = 'm3u' | 'xtream' | 'stalker';
|
||||
type CollectionMode = 'favorites' | 'recent';
|
||||
|
||||
interface ComponentOutputRef {
|
||||
subscribe(callback: () => void): { unsubscribe(): void };
|
||||
}
|
||||
|
||||
interface CollectionDetailInstance {
|
||||
closeRequested?: ComponentOutputRef;
|
||||
}
|
||||
|
||||
const EMPTY_STATE_BY_MODE: Record<
|
||||
CollectionMode,
|
||||
{ icon: string; titleKey: string; descriptionKey: string }
|
||||
@@ -44,14 +61,100 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
|
||||
return 'stalker';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-collection-detail-host',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
private readonly viewContainer = inject(ViewContainerRef);
|
||||
private readonly environmentInjector = inject(EnvironmentInjector);
|
||||
private closeSubscription: { unsubscribe(): void } | null = null;
|
||||
private renderRequestId = 0;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const item = this.item();
|
||||
|
||||
untracked(() => {
|
||||
void this.renderDetail(item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.renderRequestId++;
|
||||
this.clearDetail();
|
||||
}
|
||||
|
||||
private async renderDetail(
|
||||
item: UnifiedCollectionItem | null
|
||||
): Promise<void> {
|
||||
const requestId = ++this.renderRequestId;
|
||||
this.clearDetail();
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentType = await this.loadDetailComponent(item.sourceType);
|
||||
if (requestId !== this.renderRequestId || !componentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRef = this.viewContainer.createComponent(componentType, {
|
||||
environmentInjector: this.environmentInjector,
|
||||
});
|
||||
componentRef.setInput('item', item);
|
||||
this.subscribeToClose(componentRef.instance);
|
||||
}
|
||||
|
||||
private async loadDetailComponent(
|
||||
sourceType: UnifiedCollectionItem['sourceType']
|
||||
): Promise<Type<unknown> | null> {
|
||||
if (sourceType === 'xtream') {
|
||||
const component = await import('@iptvnator/portal/xtream/feature');
|
||||
return component.XtreamCollectionDetailComponent;
|
||||
}
|
||||
|
||||
if (sourceType === 'stalker') {
|
||||
const component = await import('@iptvnator/portal/stalker/feature');
|
||||
return component.StalkerCollectionDetailComponent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private subscribeToClose(instance: unknown): void {
|
||||
const closeRequested = (instance as CollectionDetailInstance)
|
||||
.closeRequested;
|
||||
|
||||
if (!closeRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeSubscription = closeRequested.subscribe(() => {
|
||||
this.closeRequested.emit();
|
||||
});
|
||||
}
|
||||
|
||||
private clearDetail(): void {
|
||||
this.closeSubscription?.unsubscribe();
|
||||
this.closeSubscription = null;
|
||||
this.viewContainer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-collection-route',
|
||||
imports: [
|
||||
EmptyStateComponent,
|
||||
StalkerCollectionDetailComponent,
|
||||
GlobalCollectionDetailHostComponent,
|
||||
UnifiedCollectionDetailDirective,
|
||||
UnifiedCollectionPageComponent,
|
||||
XtreamCollectionDetailComponent,
|
||||
],
|
||||
template: `
|
||||
@if (hasNoPlaylists()) {
|
||||
@@ -74,9 +177,9 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
|
||||
>
|
||||
<ng-template unifiedCollectionDetail let-item let-close="close">
|
||||
@if (item.sourceType === 'xtream') {
|
||||
<app-xtream-collection-detail [item]="item" />
|
||||
<app-global-collection-detail-host [item]="item" />
|
||||
} @else if (item.sourceType === 'stalker') {
|
||||
<app-stalker-collection-detail
|
||||
<app-global-collection-detail-host
|
||||
[item]="item"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
@@ -96,7 +199,9 @@ export class GlobalCollectionRouteComponent {
|
||||
readonly mode = input<CollectionMode>('favorites');
|
||||
readonly defaultScope = input<CollectionScope | undefined>(undefined);
|
||||
|
||||
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
private readonly playlists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
|
||||
|
||||
readonly emptyState = computed(() => EMPTY_STATE_BY_MODE[this.mode()]);
|
||||
@@ -108,8 +213,11 @@ export class GlobalCollectionRouteComponent {
|
||||
const provider = this.playlistContext.activeProvider();
|
||||
return provider ? providerToPortalType(provider) : null;
|
||||
});
|
||||
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(() =>
|
||||
this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all')
|
||||
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(
|
||||
() =>
|
||||
this.activePlaylistId()
|
||||
? 'playlist'
|
||||
: (this.defaultScope() ?? 'all')
|
||||
);
|
||||
|
||||
addPlaylist(): void {
|
||||
|
||||
@@ -42,6 +42,7 @@ interface ErrorStatus {
|
||||
})
|
||||
export class ElectronService extends DataService {
|
||||
private eventListeners: { [key: string]: () => void } = {};
|
||||
private messageListeners = new Map<string, EventListener>();
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
@@ -556,27 +557,39 @@ export class ElectronService extends DataService {
|
||||
unsubscribe()
|
||||
);
|
||||
this.eventListeners = {};
|
||||
} else if (this.eventListeners[type]) {
|
||||
// Remove all tracked window message listeners
|
||||
this.messageListeners.forEach((listener) =>
|
||||
window.removeEventListener('message', listener)
|
||||
);
|
||||
this.messageListeners.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.eventListeners[type]) {
|
||||
// Unsubscribe from a specific event
|
||||
this.eventListeners[type]();
|
||||
delete this.eventListeners[type];
|
||||
}
|
||||
|
||||
// Also remove any window message listeners
|
||||
window.removeEventListener('message', this.getListenerForCommand(type));
|
||||
}
|
||||
|
||||
private getListenerForCommand(_command: string): EventListener {
|
||||
void _command;
|
||||
// This is a placeholder. In a real implementation, you would need to
|
||||
// store the actual listener functions to be able to remove them
|
||||
return () => undefined;
|
||||
// Remove the window message listener registered for this command
|
||||
const messageListener = this.messageListeners.get(type);
|
||||
if (messageListener) {
|
||||
window.removeEventListener('message', messageListener);
|
||||
this.messageListeners.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
listenOn(command: string, callback: (...args: unknown[]) => void): void {
|
||||
// For Electron, use window message events
|
||||
void command;
|
||||
window.addEventListener('message', callback);
|
||||
// Drop any existing listener for this command so calling listenOn()
|
||||
// again rebinds rather than accumulating duplicates.
|
||||
const existing = this.messageListeners.get(command);
|
||||
if (existing) {
|
||||
window.removeEventListener('message', existing);
|
||||
}
|
||||
|
||||
const listener = callback as EventListener;
|
||||
window.addEventListener('message', listener);
|
||||
this.messageListeners.set(command, listener);
|
||||
}
|
||||
|
||||
getAppEnvironment(): string {
|
||||
|
||||
@@ -52,6 +52,7 @@ interface ErrorStatus {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PwaService extends DataService {
|
||||
private messageListeners = new Map<string, EventListener>();
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
@@ -500,12 +501,33 @@ export class PwaService extends DataService {
|
||||
});
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
// not implemented
|
||||
removeAllListeners(type: string): void {
|
||||
if (type === 'all') {
|
||||
this.messageListeners.forEach((listener) =>
|
||||
window.removeEventListener('message', listener)
|
||||
);
|
||||
this.messageListeners.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const messageListener = this.messageListeners.get(type);
|
||||
if (messageListener) {
|
||||
window.removeEventListener('message', messageListener);
|
||||
this.messageListeners.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
listenOn(_command: string, callback: (...args: unknown[]) => void): void {
|
||||
window.addEventListener('message', callback);
|
||||
listenOn(command: string, callback: (...args: unknown[]) => void): void {
|
||||
// Drop any existing listener for this command so calling listenOn()
|
||||
// again rebinds rather than accumulating duplicates.
|
||||
const existing = this.messageListeners.get(command);
|
||||
if (existing) {
|
||||
window.removeEventListener('message', existing);
|
||||
}
|
||||
|
||||
const listener = callback as EventListener;
|
||||
window.addEventListener('message', listener);
|
||||
this.messageListeners.set(command, listener);
|
||||
}
|
||||
|
||||
getAppEnvironment(): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspaceAccountInfoData,
|
||||
@@ -67,5 +68,9 @@ export function provideWorkspaceShellActions(): Provider[] {
|
||||
provide: WORKSPACE_SHELL_ACTIONS,
|
||||
useExisting: AppWorkspaceShellActionsService,
|
||||
},
|
||||
{
|
||||
provide: PORTAL_SHELL_ACTIONS,
|
||||
useExisting: AppWorkspaceShellActionsService,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'about'"
|
||||
id="about"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>info</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.ABOUT' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control version-block">
|
||||
<span>{{ version() }}</span>
|
||||
<small>{{ updateMessage() }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="settings-note">
|
||||
<strong>{{
|
||||
'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate
|
||||
}}</strong>
|
||||
<span>{{
|
||||
'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div class="link-cards">
|
||||
<a
|
||||
class="link-card link-card--github"
|
||||
href="https://github.com/4gray/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-github"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.112.793-.26.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>GitHub</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_GITHUB_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--blog"
|
||||
href="https://4gray.github.io/iptvnator/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-blog"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<mat-icon>menu_book</mat-icon>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>{{ 'SETTINGS.LINK_BLOG' | translate }}</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_BLOG_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
<a
|
||||
class="link-card link-card--telegram"
|
||||
href="https://t.me/iptvnator"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="link-telegram"
|
||||
>
|
||||
<span class="link-card__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="link-card__text">
|
||||
<strong>Telegram</strong>
|
||||
<span>{{
|
||||
'SETTINGS.LINK_TELEGRAM_DESCRIPTION' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="support-card" data-test-id="support-card">
|
||||
<div class="support-card__intro">
|
||||
<div class="support-card__emblem">
|
||||
<mat-icon>favorite</mat-icon>
|
||||
</div>
|
||||
<div class="support-card__copy">
|
||||
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="support-card__actions">
|
||||
<a
|
||||
class="support-btn support-btn--sponsors"
|
||||
href="https://github.com/sponsors/4gray"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="support-sponsors"
|
||||
>
|
||||
<span class="support-btn__glyph">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="support-btn__text">
|
||||
<strong>GitHub Sponsors</strong>
|
||||
<small>{{
|
||||
'SETTINGS.SUPPORT_SPONSORS_HINT' | translate
|
||||
}}</small>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
class="support-btn support-btn--kofi"
|
||||
href="https://ko-fi.com/4gray"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-test-id="support-kofi"
|
||||
>
|
||||
<span class="support-btn__glyph">
|
||||
<mat-icon>local_cafe</mat-icon>
|
||||
</span>
|
||||
<span class="support-btn__text">
|
||||
<strong>Ko-fi</strong>
|
||||
<small>{{
|
||||
'SETTINGS.SUPPORT_KOFI_HINT' | translate
|
||||
}}</small>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Component, input, ViewEncapsulation } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [MatIconModule, TranslateModule],
|
||||
templateUrl: './settings-about-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsAboutSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isDesktop = input(false);
|
||||
readonly version = input<string | undefined>();
|
||||
readonly updateMessage = input<string | undefined>();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'backup'"
|
||||
id="backup"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>backup</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control inline-actions">
|
||||
<button
|
||||
mat-button
|
||||
class="inline-actions__button"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
importData.emit()
|
||||
"
|
||||
>
|
||||
{{ 'SETTINGS.IMPORT_DATA' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
class="inline-actions__button"
|
||||
[class.inline-actions__button--busy]="isExportingData()"
|
||||
[disabled]="isRemovingAllPlaylists() || isExportingData()"
|
||||
[attr.aria-busy]="isExportingData()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
exportData.emit()
|
||||
"
|
||||
>
|
||||
@if (isExportingData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORTING_DATA' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.EXPORT_DATA' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isPwa()) {
|
||||
<div class="settings-note">
|
||||
{{ 'SETTINGS.EPG_NOTE' | translate }}
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-backup-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-backup-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsBackupSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isPwa = input(false);
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly isExportingData = input(false);
|
||||
|
||||
readonly importData = output<void>();
|
||||
readonly exportData = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'epg'"
|
||||
id="epg"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__control">
|
||||
<ng-container formArrayName="epgUrl">
|
||||
@if (epgUrl().length === 0) {
|
||||
<div
|
||||
class="epg-sources-empty"
|
||||
data-test-id="epg-sources-empty"
|
||||
>
|
||||
<mat-icon class="epg-sources-empty__icon"
|
||||
>event_busy</mat-icon
|
||||
>
|
||||
<h4 class="epg-sources-empty__title">
|
||||
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
|
||||
</h4>
|
||||
<p class="epg-sources-empty__hint">
|
||||
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="epg-sources">
|
||||
@for (_ of epgUrl().controls; track _; let i = $index) {
|
||||
<div class="epg-source-row">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
subscriptSizing="dynamic"
|
||||
class="w-full"
|
||||
>
|
||||
<mat-label>{{
|
||||
'SETTINGS.EPG_URL_PLACEHOLDER'
|
||||
| translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="url"
|
||||
[formControlName]="i"
|
||||
#epgField
|
||||
/>
|
||||
<mat-error>{{
|
||||
'SETTINGS.EPG_URL_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
@if (epgField.value) {
|
||||
<app-epg-source-status
|
||||
[url]="epgField.value"
|
||||
/>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REFRESH_EPG' | translate
|
||||
"
|
||||
type="button"
|
||||
[disabled]="epgField.value === ''"
|
||||
(click)="refreshEpg.emit(epgUrl().value[i])"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="
|
||||
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
||||
"
|
||||
type="button"
|
||||
(click)="removeEpgSource.emit(i)"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="settings-group__footer">
|
||||
<div class="inline-actions">
|
||||
<button mat-button type="button" (click)="addEpgSource.emit()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="epgUrl().length === 0 || isClearingEpgData()"
|
||||
(click)="refreshAllEpg.emit()"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="isClearingEpgData()"
|
||||
(click)="clearEpgData.emit()"
|
||||
>
|
||||
@if (isClearingEpgData()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
|
||||
} @else {
|
||||
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-epg-section',
|
||||
imports: [
|
||||
EpgSourceStatusComponent,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-epg-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsEpgSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly epgUrl = input.required<FormArray>();
|
||||
readonly isClearingEpgData = input(false);
|
||||
|
||||
readonly refreshEpg = output<string>();
|
||||
readonly removeEpgSource = output<number>();
|
||||
readonly addEpgSource = output<void>();
|
||||
readonly refreshAllEpg = output<void>();
|
||||
readonly clearEpgData = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FormControl, Validators } from '@angular/forms';
|
||||
|
||||
export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/;
|
||||
|
||||
export function createEpgUrlControl(value = ''): FormControl<string | null> {
|
||||
return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'general'"
|
||||
id="general"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.GENERAL' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="language"
|
||||
data-test-id="select-language"
|
||||
>
|
||||
@for (
|
||||
language of languageEnum() | keyvalue;
|
||||
track language
|
||||
) {
|
||||
<mat-option
|
||||
[value]="language.value"
|
||||
[attr.data-test-id]="language.value"
|
||||
>{{
|
||||
'LANGUAGES.' + language.key | translate
|
||||
}}</mat-option
|
||||
>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.THEME' | translate"
|
||||
data-test-id="select-theme"
|
||||
>
|
||||
@for (themeOption of themeOptions(); track themeOption.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.theme === themeOption.value
|
||||
"
|
||||
[attr.data-test-id]="themeOption.value"
|
||||
role="radio"
|
||||
(click)="selectTheme.emit(themeOption.value)"
|
||||
>
|
||||
<mat-icon>{{ themeOption.icon }}</mat-icon>
|
||||
<span>{{ themeOption.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
|
||||
data-test-id="select-cover-size"
|
||||
>
|
||||
@for (option of coverSizeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.coverSize === option.value
|
||||
"
|
||||
[attr.data-test-id]="'cover-size-' + option.value"
|
||||
role="radio"
|
||||
(click)="selectCoverSize.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showDashboard"
|
||||
data-test-id="toggle-show-dashboard"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.STARTUP_BEHAVIOR' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="startupBehavior"
|
||||
data-test-id="select-startup-behavior"
|
||||
>
|
||||
@for (
|
||||
option of startupBehaviorOptions();
|
||||
track option.value
|
||||
) {
|
||||
<mat-option
|
||||
[value]="option.value"
|
||||
[attr.data-test-id]="option.value"
|
||||
>
|
||||
{{ option.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="showCaptions"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CoverSize, Language, Theme } from 'shared-interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
StartupBehaviorOption,
|
||||
ThemeOption,
|
||||
} from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-general-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-general-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsGeneralSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly languageEnum = input.required<typeof Language>();
|
||||
readonly themeOptions = input.required<ThemeOption[]>();
|
||||
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
|
||||
readonly startupBehaviorOptions = input.required<StartupBehaviorOption[]>();
|
||||
|
||||
readonly selectTheme = output<Theme>();
|
||||
readonly selectCoverSize = output<CoverSize>();
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
CoverSize,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
SettingsPlayerOption,
|
||||
SettingsSection,
|
||||
StartupBehaviorOption,
|
||||
ThemeOption,
|
||||
} from './settings.models';
|
||||
|
||||
export const SETTINGS_THEME_OPTIONS: ThemeOption[] = [
|
||||
{
|
||||
value: Theme.LightTheme,
|
||||
icon: 'light_mode',
|
||||
labelKey: 'THEMES.LIGHT_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.DarkTheme,
|
||||
icon: 'dark_mode',
|
||||
labelKey: 'THEMES.DARK_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.SystemTheme,
|
||||
icon: 'desktop_windows',
|
||||
labelKey: 'THEMES.SYSTEM_THEME',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_COVER_SIZE_OPTIONS: CoverSizeOption[] = [
|
||||
{
|
||||
value: 'small' satisfies CoverSize,
|
||||
icon: 'view_module',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_SMALL',
|
||||
},
|
||||
{
|
||||
value: 'medium' satisfies CoverSize,
|
||||
icon: 'view_comfy',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_MEDIUM',
|
||||
},
|
||||
{
|
||||
value: 'large' satisfies CoverSize,
|
||||
icon: 'view_quilt',
|
||||
labelKey: 'SETTINGS.COVER_SIZE_LARGE',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [
|
||||
{
|
||||
value: StartupBehavior.FirstView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
|
||||
},
|
||||
{
|
||||
value: StartupBehavior.RestoreLastView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
labelKey: 'SETTINGS.PLAYER_MPV',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VLC,
|
||||
labelKey: 'SETTINGS.PLAYER_VLC',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [
|
||||
{
|
||||
id: VideoPlayer.Html5Player,
|
||||
labelKey: 'SETTINGS.PLAYER_HTML5',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VideoJs,
|
||||
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.ArtPlayer,
|
||||
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
|
||||
},
|
||||
];
|
||||
|
||||
export function buildSettingsSectionNavItems(
|
||||
isDesktop: boolean
|
||||
): SettingsSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'SETTINGS.NAV_GENERAL',
|
||||
icon: 'tune',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'playback',
|
||||
label: 'SETTINGS.NAV_PLAYBACK',
|
||||
icon: 'play_circle',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
label: 'SETTINGS.NAV_EPG',
|
||||
icon: 'calendar_month',
|
||||
visible: isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'remote-control',
|
||||
label: 'SETTINGS.NAV_REMOTE',
|
||||
icon: 'smartphone',
|
||||
visible: isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: 'SETTINGS.NAV_BACKUP',
|
||||
icon: 'backup',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'reset',
|
||||
label: 'SETTINGS.NAV_RESET',
|
||||
icon: 'delete_sweep',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'SETTINGS.NAV_ABOUT',
|
||||
icon: 'info',
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'playback'"
|
||||
id="playback"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.PLAYBACK' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="player"
|
||||
data-test-id="select-video-player"
|
||||
>
|
||||
@for (player of players(); track player.id) {
|
||||
<mat-option
|
||||
[value]="player.id"
|
||||
[attr.data-test-id]="player.id"
|
||||
>{{ player.labelKey | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.STREAM_FORMAT' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
||||
}}</mat-label>
|
||||
<mat-select
|
||||
formControlName="streamFormat"
|
||||
data-test-id="select-stream-format"
|
||||
>
|
||||
@for (
|
||||
streamFormat of streamFormatEnum() | keyvalue;
|
||||
track streamFormat
|
||||
) {
|
||||
<mat-option
|
||||
[value]="streamFormat.value"
|
||||
[attr.data-test-id]="streamFormat.value"
|
||||
>{{ streamFormat.value }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDesktop()) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="showExternalPlaybackBar"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.player === 'mpv') {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label for="mpvPlayerPath">{{
|
||||
'SETTINGS.MPV_PLAYER_PATH' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
id="mpvPlayerPath"
|
||||
formControlName="mpvPlayerPath"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox formControlName="mpvReuseInstance"></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.player === 'vlc') {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label for="vlcPlayerPath">{{
|
||||
'SETTINGS.VLC_PLAYER_PATH' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
id="vlcPlayerPath"
|
||||
formControlName="vlcPlayerPath"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, input, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { StreamFormat } from 'shared-interfaces';
|
||||
import { SettingsPlayerOption } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-playback-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-playback-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsPlaybackSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly players = input.required<SettingsPlayerOption[]>();
|
||||
readonly streamFormatEnum = input.required<typeof StreamFormat>();
|
||||
readonly isDesktop = input(false);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'remote-control'"
|
||||
id="remote-control"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>settings_remote</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
formControlName="remoteControl"
|
||||
data-test-id="remote-control-enabled"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (form().value.remoteControl === true) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'SETTINGS.PORT_RANGE_LABEL' | translate
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
id="remoteControlPort"
|
||||
data-test-id="remote-control-port"
|
||||
formControlName="remoteControlPort"
|
||||
min="1"
|
||||
max="65535"
|
||||
step="1"
|
||||
/>
|
||||
@if (
|
||||
form().get('remoteControlPort')?.hasError('required')
|
||||
) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_REQUIRED' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('min')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MIN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('max')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_MAX' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
@if (form().get('remoteControlPort')?.hasError('pattern')) {
|
||||
<mat-error>{{
|
||||
'SETTINGS.PORT_PATTERN' | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (localIpAddresses().length > 0) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control remote-control-urls">
|
||||
@for (ip of localIpAddresses(); track ip) {
|
||||
<div class="remote-control-url-item">
|
||||
<div class="url-row">
|
||||
<a
|
||||
[href]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value.remoteControlPort
|
||||
"
|
||||
target="_blank"
|
||||
class="remote-control-url"
|
||||
>
|
||||
http://{{ ip }}:{{
|
||||
form().value.remoteControlPort
|
||||
}}
|
||||
</a>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="toggleQrCode.emit(ip)"
|
||||
[matTooltip]="
|
||||
'SETTINGS.SHOW_QR_CODE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>qr_code_2</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (visibleQrCodeIp() === ip) {
|
||||
<div class="qr-code-container">
|
||||
<qrcode
|
||||
[qrdata]="
|
||||
'http://' +
|
||||
ip +
|
||||
':' +
|
||||
form().value.remoteControlPort
|
||||
"
|
||||
[width]="150"
|
||||
errorCorrectionLevel="M"
|
||||
></qrcode>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-remote-control-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatTooltipModule,
|
||||
QRCodeComponent,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-remote-control-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsRemoteControlSectionComponent {
|
||||
readonly form = input.required<FormGroup>();
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly localIpAddresses = input.required<string[]>();
|
||||
readonly visibleQrCodeIp = input<string | null>(null);
|
||||
|
||||
readonly toggleQrCode = output<string>();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
[class.settings-group--active]="activeSection() === 'reset'"
|
||||
id="reset"
|
||||
>
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
</div>
|
||||
<div class="settings-group__header-text">
|
||||
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
|
||||
<p>{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item danger-zone">
|
||||
<div class="danger-zone__header">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<button
|
||||
mat-button
|
||||
class="danger-zone__button"
|
||||
[class.danger-zone__button--busy]="isRemovingAllPlaylists()"
|
||||
[disabled]="!canRemoveAllPlaylists()"
|
||||
[attr.aria-busy]="isRemovingAllPlaylists()"
|
||||
(click)="
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
removeAll.emit()
|
||||
"
|
||||
color="warn"
|
||||
>
|
||||
@if (isRemovingAllPlaylists()) {
|
||||
<mat-progress-spinner
|
||||
class="button-spinner"
|
||||
diameter="16"
|
||||
mode="indeterminate"
|
||||
/>
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_IN_PROGRESS' | translate
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="inline-actions__label">{{
|
||||
'SETTINGS.REMOVE_ALL_BUTTON' | translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="danger-zone__summary" aria-live="polite">
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon"
|
||||
>folder_open</mat-icon
|
||||
>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.M3U' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().m3u
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">cloud</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.XTREAM' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().xtream
|
||||
}}</span>
|
||||
</span>
|
||||
<span class="danger-zone__summary-pill">
|
||||
<mat-icon class="danger-zone__summary-icon">cast</mat-icon>
|
||||
<span class="danger-zone__summary-label">{{
|
||||
'HOME.PLAYLIST_TYPES.STALKER' | translate
|
||||
}}</span>
|
||||
<span class="danger-zone__summary-count">{{
|
||||
playlistDeleteSummary().stalker
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
@if (removeAllProgressLabel(); as progressLabel) {
|
||||
<p class="danger-zone__progress">
|
||||
{{ progressLabel }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SettingsPlaylistDeleteSummary } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-reset-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './settings-reset-section.component.html',
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
styles: [':host { display: contents; }'],
|
||||
})
|
||||
export class SettingsResetSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly isRemovingAllPlaylists = input(false);
|
||||
readonly canRemoveAllPlaylists = input(false);
|
||||
readonly playlistDeleteSummary =
|
||||
input.required<SettingsPlaylistDeleteSummary>();
|
||||
readonly removeAllProgressLabel = input<string | null>(null);
|
||||
|
||||
readonly removeAll = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Directive,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import { ObservedSettingsSection } from './settings.models';
|
||||
|
||||
@Directive({
|
||||
selector: '[appSettingsSectionScroll]',
|
||||
})
|
||||
export class SettingsSectionScrollDirective
|
||||
implements AfterViewInit, OnDestroy
|
||||
{
|
||||
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
|
||||
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
|
||||
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
|
||||
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly settingsCtx = inject(SettingsContextService);
|
||||
|
||||
private sectionObserver?: IntersectionObserver;
|
||||
private pendingScrollClearTimer: ReturnType<
|
||||
typeof window.setTimeout
|
||||
> | null = null;
|
||||
private pendingScrollClearRoot: HTMLElement | null = null;
|
||||
private pendingScrollEndListener: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const sectionId = this.settingsCtx.pendingScrollTarget();
|
||||
if (!sectionId || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.scrollToSection(sectionId);
|
||||
this.schedulePendingScrollTargetClear(scrollRoot);
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
|
||||
effect(
|
||||
(onCleanup) => {
|
||||
const activeSectionId = this.settingsCtx.activeSection();
|
||||
const activeSectionElement =
|
||||
this.elementRef.nativeElement.querySelector(
|
||||
`#${activeSectionId}`
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSectionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = activeSectionElement.animate(
|
||||
[
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 260,
|
||||
easing: 'ease-out',
|
||||
}
|
||||
);
|
||||
|
||||
onCleanup(() => animation.cancel());
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
requestAnimationFrame(() => this.setupSectionObserver());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.sectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
private setupSectionObserver(): void {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const contentSections = Array.from(
|
||||
this.elementRef.nativeElement.querySelectorAll(
|
||||
'.settings-group[id]'
|
||||
)
|
||||
) as HTMLElement[];
|
||||
const sections: ObservedSettingsSection[] = contentSections.map(
|
||||
(section) => ({
|
||||
id: section.id,
|
||||
element: section,
|
||||
})
|
||||
);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sectionObserver?.disconnect();
|
||||
this.sectionObserver = new IntersectionObserver(
|
||||
() => {
|
||||
if (this.settingsCtx.pendingScrollTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSection = this.resolveActiveSection(sections);
|
||||
if (activeSection) {
|
||||
this.settingsCtx.setActiveSection(activeSection);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRoot,
|
||||
threshold: [0.12, 0.24, 0.4, 0.6],
|
||||
rootMargin: '-18% 0px -52% 0px',
|
||||
}
|
||||
);
|
||||
|
||||
sections.forEach((section) =>
|
||||
this.sectionObserver?.observe(section.element)
|
||||
);
|
||||
|
||||
const initialSection = this.resolveActiveSection(sections);
|
||||
if (initialSection) {
|
||||
this.settingsCtx.setActiveSection(initialSection);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActiveSection(
|
||||
sections: ObservedSettingsSection[]
|
||||
): string | null {
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
|
||||
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
|
||||
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
|
||||
const sectionAtActivationLine = sections.find((section) => {
|
||||
const rect = section.element.getBoundingClientRect();
|
||||
return rect.top <= activationLine && rect.bottom >= activationLine;
|
||||
});
|
||||
|
||||
if (sectionAtActivationLine) {
|
||||
return sectionAtActivationLine.id;
|
||||
}
|
||||
|
||||
const nearestSection = sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
distance: Math.abs(
|
||||
section.element.getBoundingClientRect().top - activationLine
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
|
||||
return nearestSection?.id ?? null;
|
||||
}
|
||||
|
||||
private getScrollRoot(): HTMLElement | null {
|
||||
return this.elementRef.nativeElement.closest(
|
||||
'main.workspace-content'
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
|
||||
private schedulePendingScrollTargetClear(
|
||||
scrollRoot: HTMLElement | null
|
||||
): void {
|
||||
const clearPendingScrollTarget = () => {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.settingsCtx.clearPendingScrollTarget();
|
||||
};
|
||||
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.pendingScrollClearTimer = window.setTimeout(
|
||||
clearPendingScrollTarget,
|
||||
SettingsSectionScrollDirective.PENDING_SCROLL_CLEAR_DELAY_MS
|
||||
);
|
||||
this.pendingScrollClearRoot = scrollRoot;
|
||||
this.pendingScrollEndListener = clearPendingScrollTarget;
|
||||
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private cancelPendingScrollTargetClear(): void {
|
||||
if (this.pendingScrollClearTimer) {
|
||||
clearTimeout(this.pendingScrollClearTimer);
|
||||
this.pendingScrollClearTimer = null;
|
||||
}
|
||||
|
||||
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
|
||||
this.pendingScrollClearRoot.removeEventListener?.(
|
||||
'scrollend',
|
||||
this.pendingScrollEndListener
|
||||
);
|
||||
}
|
||||
|
||||
this.pendingScrollClearRoot = null;
|
||||
this.pendingScrollEndListener = null;
|
||||
}
|
||||
|
||||
private scrollToSection(sectionId: string): HTMLElement | null {
|
||||
const sectionElement = document.getElementById(sectionId);
|
||||
if (!sectionElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
if (!scrollRoot) {
|
||||
sectionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const sectionRect = sectionElement.getBoundingClientRect();
|
||||
const sectionTop =
|
||||
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
|
||||
const sectionBottom = sectionTop + sectionRect.height;
|
||||
const visibleTop =
|
||||
scrollRoot.scrollTop +
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
|
||||
const visibleBottom =
|
||||
scrollRoot.scrollTop +
|
||||
scrollRoot.clientHeight -
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
let nextScrollTop = scrollRoot.scrollTop;
|
||||
|
||||
if (sectionTop < visibleTop) {
|
||||
nextScrollTop =
|
||||
sectionTop -
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
|
||||
} else if (sectionBottom > visibleBottom) {
|
||||
nextScrollTop =
|
||||
sectionBottom -
|
||||
scrollRoot.clientHeight +
|
||||
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
scrollRoot.scrollHeight - scrollRoot.clientHeight
|
||||
);
|
||||
|
||||
scrollRoot.scrollTo({
|
||||
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
return scrollRoot;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
:host {
|
||||
.settings-page-host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -745,11 +745,7 @@
|
||||
var(--mat-sys-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-on-surface) 38%,
|
||||
transparent
|
||||
);
|
||||
color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
PlaylistsService,
|
||||
} from 'services';
|
||||
import {
|
||||
/* EmbeddedMpvSupport, */
|
||||
EmbeddedMpvSupport,
|
||||
Language,
|
||||
PlaylistMeta,
|
||||
StartupBehavior,
|
||||
@@ -52,6 +53,7 @@ import { from, of } from 'rxjs';
|
||||
import { ElectronServiceStub } from '../services/electron.service.stub';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
|
||||
|
||||
class MatSnackBarStub {
|
||||
open = jest.fn();
|
||||
@@ -78,6 +80,7 @@ const DEFAULT_SETTINGS = {
|
||||
remoteControl: false,
|
||||
remoteControlPort: 8765,
|
||||
epgUrl: [],
|
||||
coverSize: 'medium',
|
||||
};
|
||||
|
||||
class MockSettingsStore {
|
||||
@@ -110,6 +113,15 @@ class MockSettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsSectionScrollDirectiveTestApi {
|
||||
getScrollRoot(): HTMLElement | null;
|
||||
}
|
||||
|
||||
interface SettingsComponentPrivateTestApi {
|
||||
matDialog: MatDialog;
|
||||
waitForUiFeedbackFrame(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
let component: SettingsComponent;
|
||||
let fixture: ComponentFixture<SettingsComponent>;
|
||||
@@ -117,7 +129,6 @@ describe('SettingsComponent', () => {
|
||||
let router: Router;
|
||||
let settingsStore: unknown;
|
||||
let translate: TranslateService;
|
||||
let epgService: EpgService;
|
||||
let dialogService: DialogService;
|
||||
let playlistsService: PlaylistsService;
|
||||
let playlistBackupService: PlaylistBackupService;
|
||||
@@ -139,6 +150,11 @@ describe('SettingsComponent', () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDialogRef = (result: boolean): ReturnType<MatDialog['open']> =>
|
||||
({
|
||||
afterClosed: () => of(result),
|
||||
}) as unknown as ReturnType<MatDialog['open']>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -244,7 +260,6 @@ describe('SettingsComponent', () => {
|
||||
settingsStore = TestBed.inject(SettingsStore);
|
||||
router = TestBed.inject(Router);
|
||||
translate = TestBed.inject(TranslateService);
|
||||
epgService = TestBed.inject(EpgService);
|
||||
dialogService = TestBed.inject(DialogService);
|
||||
playlistsService = TestBed.inject(PlaylistsService);
|
||||
playlistBackupService = TestBed.inject(PlaylistBackupService);
|
||||
@@ -271,6 +286,12 @@ describe('SettingsComponent', () => {
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function privateApi(
|
||||
settingsComponent: SettingsComponent
|
||||
): SettingsComponentPrivateTestApi {
|
||||
return settingsComponent as unknown as SettingsComponentPrivateTestApi;
|
||||
}
|
||||
|
||||
it('should create and init component', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
@@ -332,10 +353,14 @@ describe('SettingsComponent', () => {
|
||||
scrollComponent.fetchLocalIpAddresses = jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(scrollComponent as any, 'getScrollRoot').mockReturnValue(
|
||||
scrollRoot
|
||||
);
|
||||
scrollFixture.detectChanges();
|
||||
const scrollDirective = scrollFixture.debugElement
|
||||
.query(By.directive(SettingsSectionScrollDirective))
|
||||
.injector.get(SettingsSectionScrollDirective);
|
||||
jest.spyOn(
|
||||
scrollDirective as unknown as SettingsSectionScrollDirectiveTestApi,
|
||||
'getScrollRoot'
|
||||
).mockReturnValue(scrollRoot);
|
||||
|
||||
const getElementByIdSpy = jest
|
||||
.spyOn(document, 'getElementById')
|
||||
@@ -548,10 +573,8 @@ describe('SettingsComponent', () => {
|
||||
]);
|
||||
|
||||
const openSpy = jest
|
||||
.spyOn((component as any).matDialog, 'open')
|
||||
.mockReturnValue({
|
||||
afterClosed: () => of(false),
|
||||
} as any);
|
||||
.spyOn(privateApi(component).matDialog, 'open')
|
||||
.mockReturnValue(createDialogRef(false));
|
||||
|
||||
component.removeAll();
|
||||
|
||||
@@ -587,9 +610,9 @@ describe('SettingsComponent', () => {
|
||||
|
||||
const dispatchSpy = jest.spyOn(store, 'dispatch');
|
||||
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
|
||||
jest.spyOn((component as any).matDialog, 'open').mockReturnValue({
|
||||
afterClosed: () => of(true),
|
||||
} as any);
|
||||
jest.spyOn(privateApi(component).matDialog, 'open').mockReturnValue(
|
||||
createDialogRef(true)
|
||||
);
|
||||
jest.spyOn(translate, 'instant').mockImplementation(
|
||||
(key: string, params?: Record<string, number>) => {
|
||||
if (key === 'SETTINGS.REMOVE_ALL_PROGRESS') {
|
||||
@@ -599,7 +622,7 @@ describe('SettingsComponent', () => {
|
||||
}
|
||||
);
|
||||
jest.spyOn(
|
||||
component as any,
|
||||
privateApi(component),
|
||||
'waitForUiFeedbackFrame'
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
@@ -679,13 +702,12 @@ describe('SettingsComponent', () => {
|
||||
mockStore.refreshState();
|
||||
browserFixture.detectChanges();
|
||||
|
||||
jest.spyOn((browserComponent as any).matDialog, 'open').mockReturnValue(
|
||||
{
|
||||
afterClosed: () => of(true),
|
||||
} as any
|
||||
);
|
||||
jest.spyOn(
|
||||
browserComponent as any,
|
||||
privateApi(browserComponent).matDialog,
|
||||
'open'
|
||||
).mockReturnValue(createDialogRef(true));
|
||||
jest.spyOn(
|
||||
privateApi(browserComponent),
|
||||
'waitForUiFeedbackFrame'
|
||||
).mockResolvedValue(undefined);
|
||||
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
|
||||
@@ -845,13 +867,35 @@ describe('SettingsComponent', () => {
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
it('should update the selected theme and mark the form dirty', () => {
|
||||
component.selectTheme(Theme.DarkTheme);
|
||||
it('updates the selected theme through the general section and marks the form dirty', () => {
|
||||
const darkThemeButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector('[data-test-id="DARK_THEME"]') as HTMLButtonElement;
|
||||
|
||||
darkThemeButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsForm.value.theme).toBe(Theme.DarkTheme);
|
||||
expect(component.settingsForm.dirty).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates cover size through the general section output', () => {
|
||||
const mockStore = settingsStore as unknown as MockSettingsStore;
|
||||
const largeCoverButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector(
|
||||
'[data-test-id="cover-size-large"]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
largeCoverButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsForm.value.coverSize).toBe('large');
|
||||
expect(mockStore.updateSettings).toHaveBeenCalledWith({
|
||||
coverSize: 'large',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders workspace startup controls with the expected defaults', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
|
||||
@@ -2,44 +2,32 @@ import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormArray,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
PlaylistActions,
|
||||
@@ -57,6 +45,7 @@ import {
|
||||
} from 'services';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
CoverSize,
|
||||
Language,
|
||||
StartupBehavior,
|
||||
StreamFormat,
|
||||
@@ -65,63 +54,57 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from './../services/settings.service';
|
||||
import { SettingsAboutSectionComponent } from './settings-about-section.component';
|
||||
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
|
||||
import {
|
||||
SettingsDeleteAllPlaylistsDialogComponent,
|
||||
SettingsDeleteAllPlaylistsDialogData,
|
||||
} from './settings-delete-all-playlists-dialog.component';
|
||||
|
||||
interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface ObservedSettingsSection {
|
||||
id: string;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
interface ThemeOption {
|
||||
value: Theme;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface StartupBehaviorOption {
|
||||
value: StartupBehavior;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
type SettingsPlaylistDeleteSummary =
|
||||
SettingsDeleteAllPlaylistsDialogData['summary'];
|
||||
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
|
||||
import { createEpgUrlControl } from './settings-form.utils';
|
||||
import { SettingsGeneralSectionComponent } from './settings-general-section.component';
|
||||
import {
|
||||
SettingsPlaylistDeleteSummary,
|
||||
SettingsSection,
|
||||
} from './settings.models';
|
||||
import {
|
||||
buildSettingsSectionNavItems,
|
||||
SETTINGS_COVER_SIZE_OPTIONS,
|
||||
SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
SETTINGS_OS_PLAYER_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
} from './settings-options';
|
||||
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
|
||||
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
|
||||
import { SettingsResetSectionComponent } from './settings-reset-section.component';
|
||||
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
|
||||
|
||||
@Component({
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss'],
|
||||
host: {
|
||||
class: 'settings-page-host',
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
imports: [
|
||||
CommonModule,
|
||||
EpgSourceStatusComponent,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDividerModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatTooltipModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
MatDialogModule,
|
||||
QRCodeComponent,
|
||||
SettingsAboutSectionComponent,
|
||||
SettingsBackupSectionComponent,
|
||||
SettingsEpgSectionComponent,
|
||||
SettingsGeneralSectionComponent,
|
||||
SettingsPlaybackSectionComponent,
|
||||
SettingsRemoteControlSectionComponent,
|
||||
SettingsResetSectionComponent,
|
||||
SettingsSectionScrollDirective,
|
||||
],
|
||||
})
|
||||
export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
|
||||
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
|
||||
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
|
||||
|
||||
private dialogService = inject(DialogService);
|
||||
public dataService = inject(DataService);
|
||||
private epgService = inject(EpgService);
|
||||
@@ -134,8 +117,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private translate = inject(TranslateService);
|
||||
private matDialog = inject(MatDialog);
|
||||
private playlistBackupService = inject(PlaylistBackupService);
|
||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dialogData = inject<{ isDialog: boolean } | null>(
|
||||
MAT_DIALOG_DATA,
|
||||
@@ -170,30 +151,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: VideoPlayer.MPV,
|
||||
labelKey: 'SETTINGS.PLAYER_MPV',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VLC,
|
||||
labelKey: 'SETTINGS.PLAYER_VLC',
|
||||
},
|
||||
...SETTINGS_OS_PLAYER_OPTIONS,
|
||||
]);
|
||||
|
||||
/** Player options */
|
||||
readonly players = computed(() => [
|
||||
{
|
||||
id: VideoPlayer.Html5Player,
|
||||
labelKey: 'SETTINGS.PLAYER_HTML5',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.VideoJs,
|
||||
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
|
||||
},
|
||||
{
|
||||
id: VideoPlayer.ArtPlayer,
|
||||
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
|
||||
},
|
||||
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
...(this.isDesktop ? this.osPlayers() : []),
|
||||
]);
|
||||
|
||||
@@ -207,34 +170,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
epgAvailable$ = this.store.select(selectIsEpgAvailable);
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
readonly themeOptions: ThemeOption[] = [
|
||||
{
|
||||
value: Theme.LightTheme,
|
||||
icon: 'light_mode',
|
||||
labelKey: 'THEMES.LIGHT_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.DarkTheme,
|
||||
icon: 'dark_mode',
|
||||
labelKey: 'THEMES.DARK_THEME',
|
||||
},
|
||||
{
|
||||
value: Theme.SystemTheme,
|
||||
icon: 'desktop_windows',
|
||||
labelKey: 'THEMES.SYSTEM_THEME',
|
||||
},
|
||||
];
|
||||
|
||||
readonly startupBehaviorOptions: StartupBehaviorOption[] = [
|
||||
{
|
||||
value: StartupBehavior.FirstView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
|
||||
},
|
||||
{
|
||||
value: StartupBehavior.RestoreLastView,
|
||||
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
|
||||
},
|
||||
];
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
|
||||
/** Settings form object */
|
||||
settingsForm = this.formBuilder.group({
|
||||
@@ -260,6 +198,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
Validators.pattern(/^\d+$/),
|
||||
],
|
||||
],
|
||||
coverSize: 'medium' as CoverSize,
|
||||
});
|
||||
|
||||
/** Form array with epg sources */
|
||||
@@ -276,57 +215,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
readonly removeAllProgress = signal<DbOperationEvent | null>(null);
|
||||
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private sectionObserver?: IntersectionObserver;
|
||||
private pendingScrollClearTimer: ReturnType<
|
||||
typeof window.setTimeout
|
||||
> | null = null;
|
||||
private pendingScrollClearRoot: HTMLElement | null = null;
|
||||
private pendingScrollEndListener: (() => void) | null = null;
|
||||
|
||||
readonly sectionNavItems: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'SETTINGS.NAV_GENERAL',
|
||||
icon: 'tune',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'playback',
|
||||
label: 'SETTINGS.NAV_PLAYBACK',
|
||||
icon: 'play_circle',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
label: 'SETTINGS.NAV_EPG',
|
||||
icon: 'calendar_month',
|
||||
visible: this.isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'remote-control',
|
||||
label: 'SETTINGS.NAV_REMOTE',
|
||||
icon: 'smartphone',
|
||||
visible: this.isDesktop,
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
label: 'SETTINGS.NAV_BACKUP',
|
||||
icon: 'backup',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'reset',
|
||||
label: 'SETTINGS.NAV_RESET',
|
||||
icon: 'delete_sweep',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: 'SETTINGS.NAV_ABOUT',
|
||||
icon: 'info',
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems(
|
||||
this.isDesktop
|
||||
);
|
||||
|
||||
readonly playlistDeleteSummary = computed<SettingsPlaylistDeleteSummary>(
|
||||
() => {
|
||||
@@ -372,59 +263,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
return this.translate.instant('SETTINGS.REMOVE_ALL_IN_PROGRESS');
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
const sectionId = this.settingsCtx.pendingScrollTarget();
|
||||
if (!sectionId || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.scrollToSection(sectionId);
|
||||
this.schedulePendingScrollTargetClear(scrollRoot);
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
|
||||
effect(
|
||||
(onCleanup) => {
|
||||
const activeSectionId = this.activeSection();
|
||||
const activeSectionElement =
|
||||
this.elementRef.nativeElement.querySelector(
|
||||
`#${activeSectionId}`
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!activeSectionElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animation = activeSectionElement.animate(
|
||||
[
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 12px 22px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
{
|
||||
boxShadow:
|
||||
'inset 0 0 0 1px var(--settings-group-active-ring), 0 8px 18px -24px var(--settings-group-active-glow)',
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 260,
|
||||
easing: 'ease-out',
|
||||
}
|
||||
);
|
||||
|
||||
onCleanup(() => animation.cancel());
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
get sectionNav(): SettingsSection[] {
|
||||
return this.sectionNavItems.filter((section) => section.visible);
|
||||
}
|
||||
@@ -444,8 +282,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
if (!this.isDialog) {
|
||||
this.settingsCtx.setSections(this.sectionNav);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.setupSectionObserver());
|
||||
}
|
||||
|
||||
private async loadEmbeddedMpvSupport(): Promise<void> {
|
||||
@@ -481,8 +317,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.sectionObserver?.disconnect();
|
||||
this.settingsCtx.reset();
|
||||
}
|
||||
|
||||
@@ -531,22 +365,29 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.settingsService.changeTheme(theme);
|
||||
}
|
||||
|
||||
selectCoverSize(size: CoverSize): void {
|
||||
if (this.settingsForm.value.coverSize === size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.settingsForm.patchValue({ coverSize: size });
|
||||
this.settingsForm.get('coverSize')?.markAsDirty();
|
||||
this.settingsForm.markAsDirty();
|
||||
this.settingsStore.updateSettings({ coverSize: size });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the epg urls to the form array
|
||||
* @param epgUrls urls of the EPG sources
|
||||
*/
|
||||
setEpgUrls(epgUrls: string[] | string): void {
|
||||
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
|
||||
|
||||
const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls];
|
||||
const filteredUrls = urls
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url !== '');
|
||||
|
||||
filteredUrls.forEach((url) => {
|
||||
this.epgUrl.push(
|
||||
new FormControl(url, [Validators.pattern(URL_REGEX)])
|
||||
);
|
||||
this.epgUrl.push(createEpgUrlControl(url));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -692,14 +533,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
* Initializes new entry in form array for EPG URL
|
||||
*/
|
||||
addEpgSource(): void {
|
||||
this.epgUrl.insert(
|
||||
this.epgUrl.length,
|
||||
new FormControl('', {
|
||||
validators: [
|
||||
Validators.pattern(/^(http|https|file):\/\/[^ "]+$/),
|
||||
],
|
||||
})
|
||||
);
|
||||
this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -950,179 +784,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.removeAllProgress.set(event);
|
||||
}
|
||||
|
||||
private setupSectionObserver(): void {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const contentSections = Array.from(
|
||||
this.elementRef.nativeElement.querySelectorAll(
|
||||
'.settings-group[id]'
|
||||
)
|
||||
) as HTMLElement[];
|
||||
const sections: ObservedSettingsSection[] = contentSections.map(
|
||||
(section) => ({
|
||||
id: section.id,
|
||||
element: section,
|
||||
})
|
||||
);
|
||||
|
||||
if (sections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sectionObserver?.disconnect();
|
||||
this.sectionObserver = new IntersectionObserver(
|
||||
() => {
|
||||
if (this.settingsCtx.pendingScrollTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSection = this.resolveActiveSection(sections);
|
||||
if (activeSection) {
|
||||
this.settingsCtx.setActiveSection(activeSection);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollRoot,
|
||||
threshold: [0.12, 0.24, 0.4, 0.6],
|
||||
rootMargin: '-18% 0px -52% 0px',
|
||||
}
|
||||
);
|
||||
|
||||
sections.forEach((section) =>
|
||||
this.sectionObserver?.observe(section.element)
|
||||
);
|
||||
|
||||
const initialSection = this.resolveActiveSection(sections);
|
||||
if (initialSection) {
|
||||
this.settingsCtx.setActiveSection(initialSection);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveActiveSection(
|
||||
sections: ObservedSettingsSection[]
|
||||
): string | null {
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
|
||||
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
|
||||
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
|
||||
const sectionAtActivationLine = sections.find((section) => {
|
||||
const rect = section.element.getBoundingClientRect();
|
||||
return rect.top <= activationLine && rect.bottom >= activationLine;
|
||||
});
|
||||
|
||||
if (sectionAtActivationLine) {
|
||||
return sectionAtActivationLine.id;
|
||||
}
|
||||
|
||||
const nearestSection = sections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
distance: Math.abs(
|
||||
section.element.getBoundingClientRect().top - activationLine
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
|
||||
return nearestSection?.id ?? null;
|
||||
}
|
||||
|
||||
private getScrollRoot(): HTMLElement | null {
|
||||
return this.elementRef.nativeElement.closest(
|
||||
'main.workspace-content'
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
|
||||
private schedulePendingScrollTargetClear(
|
||||
scrollRoot: HTMLElement | null
|
||||
): void {
|
||||
const clearPendingScrollTarget = () => {
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.settingsCtx.clearPendingScrollTarget();
|
||||
};
|
||||
|
||||
this.cancelPendingScrollTargetClear();
|
||||
this.pendingScrollClearTimer = window.setTimeout(
|
||||
clearPendingScrollTarget,
|
||||
SettingsComponent.PENDING_SCROLL_CLEAR_DELAY_MS
|
||||
);
|
||||
this.pendingScrollClearRoot = scrollRoot;
|
||||
this.pendingScrollEndListener = clearPendingScrollTarget;
|
||||
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private cancelPendingScrollTargetClear(): void {
|
||||
if (this.pendingScrollClearTimer) {
|
||||
clearTimeout(this.pendingScrollClearTimer);
|
||||
this.pendingScrollClearTimer = null;
|
||||
}
|
||||
|
||||
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
|
||||
this.pendingScrollClearRoot.removeEventListener?.(
|
||||
'scrollend',
|
||||
this.pendingScrollEndListener
|
||||
);
|
||||
}
|
||||
|
||||
this.pendingScrollClearRoot = null;
|
||||
this.pendingScrollEndListener = null;
|
||||
}
|
||||
|
||||
private scrollToSection(sectionId: string): HTMLElement | null {
|
||||
const sectionElement = document.getElementById(sectionId);
|
||||
if (!sectionElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollRoot = this.getScrollRoot();
|
||||
if (!scrollRoot) {
|
||||
sectionElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootRect = scrollRoot.getBoundingClientRect();
|
||||
const sectionRect = sectionElement.getBoundingClientRect();
|
||||
const sectionTop =
|
||||
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
|
||||
const sectionBottom = sectionTop + sectionRect.height;
|
||||
const visibleTop =
|
||||
scrollRoot.scrollTop + SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
|
||||
const visibleBottom =
|
||||
scrollRoot.scrollTop +
|
||||
scrollRoot.clientHeight -
|
||||
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
let nextScrollTop = scrollRoot.scrollTop;
|
||||
|
||||
if (sectionTop < visibleTop) {
|
||||
nextScrollTop =
|
||||
sectionTop - SettingsComponent.SECTION_SCROLL_TOP_GUTTER;
|
||||
} else if (sectionBottom > visibleBottom) {
|
||||
nextScrollTop =
|
||||
sectionBottom -
|
||||
scrollRoot.clientHeight +
|
||||
SettingsComponent.SECTION_SCROLL_BOTTOM_GUTTER;
|
||||
}
|
||||
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
scrollRoot.scrollHeight - scrollRoot.clientHeight
|
||||
);
|
||||
|
||||
scrollRoot.scrollTo({
|
||||
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
return scrollRoot;
|
||||
}
|
||||
|
||||
private openSettingsSnackbar(
|
||||
message: string,
|
||||
config: MatSnackBarConfig = {}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CoverSize,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
export interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export interface ObservedSettingsSection {
|
||||
id: string;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
export interface ThemeOption {
|
||||
value: Theme;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface StartupBehaviorOption {
|
||||
value: StartupBehavior;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface CoverSizeOption {
|
||||
value: CoverSize;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface SettingsPlayerOption {
|
||||
id: VideoPlayer;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface SettingsPlaylistDeleteSummary {
|
||||
total: number;
|
||||
m3u: number;
|
||||
xtream: number;
|
||||
stalker: number;
|
||||
}
|
||||
@@ -255,6 +255,7 @@
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"TIMESHIFT_AVAILABLE": "التحريك الزمني متاح، انقر للتشغيل",
|
||||
"EPG_NOT_AVAILABLE_DATE": "عذراً، الدليل غير متاح لهذا التاريخ",
|
||||
"NO_GUIDE_HINT": "جرب تاريخاً آخر أو حدّث قائمة التشغيل.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "عذراً، الدليل غير متاح لهذه القناة",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "يرجى إضافة/تعديل رابط EPG في إعدادات التطبيق.",
|
||||
"FETCH_EPG": "جارٍ جلب بيانات EPG...",
|
||||
@@ -269,7 +270,10 @@
|
||||
"LANGUAGE": "اللغة",
|
||||
"SHOW_PROGRAM_DETAILS": "عرض تفاصيل البرنامج"
|
||||
},
|
||||
"FETCH_SUCCESS": "تم جلب الدليل بنجاح."
|
||||
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
"LIVE_STREAM": "بث مباشر",
|
||||
"TIMESHIFT_AVAILABLE": "التأجيل الزمني متوفر، ضغط باش تلعب",
|
||||
"EPG_NOT_AVAILABLE_DATE": "أوه، EPG ما متوفرش للتاريخ اللي اختاريتيه",
|
||||
"NO_GUIDE_HINT": "جرب تاريخ آخر أو حدّث اللائحة.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "أوه، EPG ما متوفرش لهاد القناة.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "عفاك زيد/بدل رابط قائمة EPG في إعدادات التطبيق.",
|
||||
"FETCH_EPG": "جاري جلب بيانات EPG...",
|
||||
@@ -261,7 +262,10 @@
|
||||
"LANGUAGE": "اللغة",
|
||||
"SHOW_PROGRAM_DETAILS": "وري تفاصيل حول هاد البرنامج"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG تجلب بنجاح"
|
||||
"FETCH_SUCCESS": "EPG تجلب بنجاح",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -244,6 +244,7 @@
|
||||
"LIVE_STREAM": "Прамая трансляцыя",
|
||||
"TIMESHIFT_AVAILABLE": "Даступная функцыя архіва (timeshift). Націсніце на перадачу для прайгравання.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ой, праграма на гэты дзень, на жаль, адсутнічае.",
|
||||
"NO_GUIDE_HINT": "Паспрабуйце іншую дату або абнавіце плэйліст.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Праграма для гэтага канала недаступная.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Паспрабуйце змяніць або абнавіць спасылку на EPG у наладах праграмы.",
|
||||
"FETCH_EPG": "Загружаю праграму ТБ-перадач (EPG)...",
|
||||
@@ -257,7 +258,10 @@
|
||||
"DESCRIPTION": "Апісанне",
|
||||
"LANGUAGE": "Мова",
|
||||
"SHOW_PROGRAM_DETAILS": "Паказаць падрабязнасці"
|
||||
}
|
||||
},
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"NO": "Не",
|
||||
"YES": "Так",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, leider ist das TV-Programm nicht verfügbar.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ups, leider ist das TV-Programm für das ausgewählte Datum nicht verfügbar",
|
||||
"NO_GUIDE_HINT": "Versuchen Sie ein anderes Datum oder aktualisieren Sie die Playlist.",
|
||||
"LIVE_NOW": "Live jetzt",
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"NEXT_DAY": "Zum nächsten Tag",
|
||||
@@ -54,7 +55,10 @@
|
||||
"DESCRIPTION": "Beschreibung",
|
||||
"LANGUAGE": "Sprache",
|
||||
"SHOW_PROGRAM_DETAILS": "Details anzeigen"
|
||||
}
|
||||
},
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"HOME": {
|
||||
"SUBTITLE": "Wählen Sie eine Playlist aus oder laden Sie eine neue Playlist hoch",
|
||||
@@ -111,7 +115,7 @@
|
||||
"REFRESH_XTREAM": "Xtream-Playlist vom Server aktualisieren",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "Xtream-Playlist aktualisieren",
|
||||
"MESSAGE": "Dadurch werden lokal zwischengespeicherte Kategorien und Streams gelöscht und alles vom Remote-Server erneut importiert. Ihre Favoriten und kürzlich angesehenen Elemente bleiben erhalten. Möchten Sie wirklich fortfahren?",
|
||||
"MESSAGE": "Dadurch wird der lokale Xtream-Cache gelöscht und Kategorien sowie Streams werden vom Remote-Server erneut importiert. Favoriten, kürzlich angesehene Elemente, ausgeblendete Kategorien und Wiedergabefortschritt werden nach einem erfolgreichen Re-Import wiederhergestellt, sofern die passenden Inhalte noch vorhanden sind. Wenn der Server nicht erreichbar ist, bleibt die Wiederherstellung bis zu einer späteren erfolgreichen Aktualisierung ausstehend. Fortfahren?",
|
||||
"STARTED": "Aktualisierung gestartet. Navigation zur Playlist...",
|
||||
"ERROR": "Playlist-Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"IN_PROGRESS": "Playlist wird aktualisiert...",
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"REFRESH_XTREAM": "Ανανέωση Xtream λίστας από το τηλεκοντρόλ",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "Ανανέωση Xtream λίστας",
|
||||
"MESSAGE": "Αυτό θα διαγράψει τις τοπικά αποθηκευμένες κατηγορίες και ροές και θα εισαγάγει ξανά τα πάντα από τον απομακρυσμένο διακομιστή. Τα αγαπημένα σας και τα πρόσφατα προβληθέντα στοιχεία θα διατηρηθούν. Είστε βέβαιοι ότι θέλετε να συνεχίσετε;",
|
||||
"MESSAGE": "Αυτό θα διαγράψει την τοπική προσωρινή μνήμη Xtream και θα εισαγάγει ξανά τις κατηγορίες και τις ροές από τον απομακρυσμένο διακομιστή. Τα αγαπημένα, τα πρόσφατα προβληθέντα στοιχεία, η ορατότητα των κρυφών κατηγοριών και η πρόοδος αναπαραγωγής θα αποκατασταθούν μετά από επιτυχημένη επανεισαγωγή, εφόσον το αντίστοιχο περιεχόμενο εξακολουθεί να υπάρχει. Αν ο διακομιστής δεν είναι προσβάσιμος, η αποκατάσταση θα παραμείνει σε εκκρεμότητα μέχρι μια μεταγενέστερη επιτυχημένη ανανέωση. Συνέχεια;",
|
||||
"STARTED": "Η ανανέωση ξεκίνησε. Πλοήγηση στη λίστα αναπαραγωγής...",
|
||||
"ERROR": "Η ανανέωση της λίστας αναπαραγωγής απέτυχε. Δοκιμάστε ξανά."
|
||||
},
|
||||
@@ -301,6 +301,7 @@
|
||||
"LIVE_STREAM": "Ζωντανή ροή",
|
||||
"TIMESHIFT_AVAILABLE": "Η χρονική μετατόπιση είναι διαθέσιμη, κάντε κλικ για αναπαραγωγή.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ωχ, το EPG δεν είναι διαθέσιμο για την επιλεγμένη ημερομηνία",
|
||||
"NO_GUIDE_HINT": "Δοκιμάστε άλλη ημερομηνία ή ανανεώστε τη λίστα αναπαραγωγής.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ωχ, το EPG δεν είναι διαθέσιμο για αυτό το κανάλι.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Προσθέστε/αλλάξτε τη διεύθυνση URL της λίστας EPG στις ρυθμίσεις της εφαρμογής.",
|
||||
"FETCH_EPG": "Ανάκτηση δεδομένων EPG...",
|
||||
@@ -327,7 +328,10 @@
|
||||
"TOTAL_CHANNELS": "Σύνολο καναλιών",
|
||||
"TOTAL_PROGRAMS": "Σύνολο προγραμμάτων",
|
||||
"UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός",
|
||||
"RETRY": "Επανάληψη"
|
||||
"RETRY": "Επανάληψη",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"REFRESH_XTREAM": "Refresh Xtream playlist from remote",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "Refresh Xtream Playlist",
|
||||
"MESSAGE": "This will delete locally cached categories and streams and re-import everything from the remote server. Your favorites and recently viewed items will be preserved. Are you sure you want to continue?",
|
||||
"MESSAGE": "This will delete the local Xtream cache and re-import categories and streams from the remote server. Favorites, recently viewed items, hidden category visibility, and playback progress are restored after a successful re-import when matching content still exists. If the server cannot be reached, the restore remains pending until a later successful refresh. Continue?",
|
||||
"STARTED": "Refresh started. Navigating to playlist...",
|
||||
"ERROR": "Failed to refresh playlist. Please try again.",
|
||||
"IN_PROGRESS": "Refreshing playlist...",
|
||||
@@ -264,6 +264,11 @@
|
||||
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
|
||||
"LANGUAGE_DESCRIPTION": "Select language of the application",
|
||||
"THEME_DESCRIPTION": "Select visual theme of the application",
|
||||
"COVER_SIZE": "Cover size",
|
||||
"COVER_SIZE_DESCRIPTION": "Adjust the size of poster cards across grids and rails. Larger sizes are easier to see on TVs; smaller sizes show more content per row on big monitors.",
|
||||
"COVER_SIZE_SMALL": "Small",
|
||||
"COVER_SIZE_MEDIUM": "Medium",
|
||||
"COVER_SIZE_LARGE": "Large",
|
||||
"STARTUP_BEHAVIOR": "Startup view",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "First available view",
|
||||
@@ -372,6 +377,7 @@
|
||||
"LOADING_MORE": "Loading more...",
|
||||
"NO_SEARCH_RESULTS": "No channels found",
|
||||
"NO_CHANNELS_FOUND": "No channels found",
|
||||
"NO_GROUPS_FOUND": "No groups found",
|
||||
"TRY_DIFFERENT_SEARCH": "Try a different search term",
|
||||
"DETAILS_DIALOG": {
|
||||
"TITLE": "Channel details",
|
||||
@@ -432,7 +438,8 @@
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift is available, click to play",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE": "Archive playback is unavailable",
|
||||
"ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION": "This provider exposes schedule history for the channel, but does not expose playable archive/catch-up playback.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ooops, EPG is not available for the selected date",
|
||||
"EPG_NOT_AVAILABLE_DATE": "No program guide",
|
||||
"NO_GUIDE_HINT": "Try a different date or refresh the playlist.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG is not available for this channel.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Please add/change the URL of the EPG list in the settings of the app.",
|
||||
"FETCH_EPG": "Fetching EPG data...",
|
||||
@@ -459,7 +466,10 @@
|
||||
"TOTAL_CHANNELS": "Total channels",
|
||||
"TOTAL_PROGRAMS": "Total programs",
|
||||
"UP_TO_DATE": "EPG is up-to-date, no sync needed",
|
||||
"RETRY": "Retry"
|
||||
"RETRY": "Retry",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
@@ -654,7 +664,7 @@
|
||||
},
|
||||
"EMPTY_LIST_VIEW": {
|
||||
"TITLE": "No results found",
|
||||
"NO_SEARCH_RESULTS": "Nothing found, try to change you search request"
|
||||
"NO_SEARCH_RESULTS": "Nothing found, try to change your search request."
|
||||
},
|
||||
"SEARCH_VIEW": {
|
||||
"INITIAL_TITLE": "Search for content",
|
||||
@@ -757,7 +767,7 @@
|
||||
"OPEN_DASHBOARD": "Open dashboard",
|
||||
"RAIL_SOURCES": "Sources",
|
||||
"OPEN_SOURCES": "Open sources",
|
||||
"RAIL_GLOBAL_FAVORITES": "Global favorites",
|
||||
"RAIL_GLOBAL_FAVORITES": "Favorites",
|
||||
"RAIL_GLOBAL_RECENT": "Recently viewed",
|
||||
"RAIL_SETTINGS": "Settings",
|
||||
"OPEN_SETTINGS": "Open settings",
|
||||
@@ -871,7 +881,10 @@
|
||||
"GO_TO_LIVE_LABEL": "Live TV",
|
||||
"GO_TO_LIVE_DESCRIPTION": "Jump to live TV section",
|
||||
"GO_TO_SERIES_LABEL": "Series",
|
||||
"GO_TO_SERIES_DESCRIPTION": "Jump to series section"
|
||||
"GO_TO_SERIES_DESCRIPTION": "Jump to series section",
|
||||
"SWITCH_PLAYER_LABEL": "Switch player to {{name}}",
|
||||
"SWITCH_PLAYER_DESCRIPTION": "Set {{name}} as the active video player",
|
||||
"SWITCH_PLAYER_FEEDBACK": "Player set to {{name}}"
|
||||
}
|
||||
},
|
||||
"SOURCES": {
|
||||
@@ -914,7 +927,8 @@
|
||||
"GROUP_GLOBAL": "Global actions",
|
||||
"GROUP_PLAYLIST": "This playlist",
|
||||
"GROUP_VIEW": "Current view",
|
||||
"GROUP_SECTION": "This section"
|
||||
"GROUP_SECTION": "This section",
|
||||
"GROUP_RECENT": "Recently used"
|
||||
},
|
||||
"GLOBAL_FAVORITES": {
|
||||
"TITLE": "Global Favorites",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"LIVE_STREAM": "Transmisión en vivo",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift está disponible, clic para reproducir",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Vaya, no está disponible la EPG de esa fecha",
|
||||
"NO_GUIDE_HINT": "Prueba otra fecha o actualiza la lista de reproducción.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Vaya, no está disponible la EPG del canal.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Por favor añada/cargue la url de la lista EPG en los ajustes de la app.",
|
||||
"FETCH_EPG": "Obteniendo datos de EPG...",
|
||||
@@ -285,7 +286,10 @@
|
||||
"DESCRIPTION": "Descripción",
|
||||
"LANGUAGE": "Idioma",
|
||||
"SHOW_PROGRAM_DETAILS": "Mostrar detalles del programa"
|
||||
}
|
||||
},
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"REFRESH_XTREAM": "Actualiser la liste Xtream depuis le serveur",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "Actualiser la liste Xtream",
|
||||
"MESSAGE": "Cela supprimera les catégories et flux mis en cache localement et réimportera tout depuis le serveur distant. Vos favoris et éléments récemment consultés seront conservés. Voulez-vous continuer ?",
|
||||
"MESSAGE": "Cela supprimera le cache Xtream local et réimportera les catégories et les flux depuis le serveur distant. Les favoris, les éléments récemment consultés, la visibilité des catégories masquées et la progression de lecture seront restaurés après une réimportation réussie si le contenu correspondant existe toujours. Si le serveur est inaccessible, la restauration restera en attente jusqu'à une prochaine actualisation réussie. Continuer ?",
|
||||
"STARTED": "Actualisation démarrée. Navigation vers la liste de lecture...",
|
||||
"ERROR": "Échec de l'actualisation de la liste de lecture. Veuillez réessayer."
|
||||
},
|
||||
@@ -269,6 +269,7 @@
|
||||
"LIVE_STREAM": "Diffusion en direct",
|
||||
"TIMESHIFT_AVAILABLE": "Contrôle du direct disponible, cliquez pour lire",
|
||||
"EPG_NOT_AVAILABLE_DATE": "L'EPG n'est pas disponible pour la date sélectionnée",
|
||||
"NO_GUIDE_HINT": "Essayez une autre date ou actualisez la playlist.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "L'EPG n'est pas disponible pour cette chaîne.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Veuillez ajouter/modifier l'URL de la liste EPG dans les réglages de l'application.",
|
||||
"FETCH_EPG": "Récupération des données EPG...",
|
||||
@@ -283,7 +284,10 @@
|
||||
"LANGUAGE": "Langue",
|
||||
"SHOW_PROGRAM_DETAILS": "Afficher les détails de ce programme"
|
||||
},
|
||||
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès"
|
||||
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
"LIVE_STREAM": "Stream live",
|
||||
"TIMESHIFT_AVAILABLE": "Disponibile il timeshift, fai clic per riprodurre",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Non è disponibile l'EPG per la data selezionata",
|
||||
"NO_GUIDE_HINT": "Prova una data diversa o aggiorna la playlist.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Non è disponibile l'EPG per questo canale.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Aggiungi/modifica l'URL dell'elenco EPG nelle impostazioni dell'app.",
|
||||
"FETCH_EPG": "Recupero dati EPG...",
|
||||
@@ -303,7 +304,10 @@
|
||||
"LANGUAGE": "Lingua",
|
||||
"SHOW_PROGRAM_DETAILS": "Visualizza dettagli su questo programma"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG scaricato correttamente"
|
||||
"FETCH_SUCCESS": "EPG scaricato correttamente",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
"LIVE_STREAM": "ライブストリーム",
|
||||
"TIMESHIFT_AVAILABLE": "タイムシフトが利用可能です。クリックして再生",
|
||||
"EPG_NOT_AVAILABLE_DATE": "申し訳ありません。選択した日付のEPGは利用できません",
|
||||
"NO_GUIDE_HINT": "別の日付を試すか、プレイリストを更新してください。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "申し訳ありません。このチャンネルのEPGは利用できません。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "アプリの設定でEPGリストのURLを追加/変更してください。",
|
||||
"FETCH_EPG": "EPGデータを取得中...",
|
||||
@@ -249,7 +250,10 @@
|
||||
"LANGUAGE": "言語",
|
||||
"SHOW_PROGRAM_DETAILS": "この番組の詳細を表示"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPGの取得に成功しました"
|
||||
"FETCH_SUCCESS": "EPGの取得に成功しました",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift를 사용할 수 있습니다, 클릭하여 재생합니다",
|
||||
"EPG_NOT_AVAILABLE_DATE": "선택한 날짜에는 EPG를 사용할 수 없습니다.",
|
||||
"NO_GUIDE_HINT": "다른 날짜를 시도하거나 재생 목록을 새로 고치세요.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "이 채널에는 EPG를 사용할 수 없습니다.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "설정에서 EPG의 URL을 추가/변경하십시오.",
|
||||
"FETCH_EPG": "EPG 데이터 가져 오기...",
|
||||
@@ -214,7 +215,10 @@
|
||||
"DESCRIPTION": "설명",
|
||||
"LANGUAGE": "언어",
|
||||
"SHOW_PROGRAM_DETAILS": "이 프로그램에 대한 세부 정보 표시"
|
||||
}
|
||||
},
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
"LIVE_STREAM": "Live stream",
|
||||
"TIMESHIFT_AVAILABLE": "Doorspoelen is beschikbaar, klik om af te spelen",
|
||||
"EPG_NOT_AVAILABLE_DATE": "De TV-Gids is niet beschikbaar voor de geselecteerde datum",
|
||||
"NO_GUIDE_HINT": "Probeer een andere datum of vernieuw de afspeellijst.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "De TV-Gids is niet beschikbaar voor dit kanaal.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Voeg een TV-Gids URL toe of wijzig deze in de instellingen van de app.",
|
||||
"FETCH_EPG": "Ophalen TV-Gids...",
|
||||
@@ -261,7 +262,10 @@
|
||||
"LANGUAGE": "Taal",
|
||||
"SHOW_PROGRAM_DETAILS": "Toon details over dit programma"
|
||||
},
|
||||
"FETCH_SUCCESS": "TV-Gids bijgewerkt"
|
||||
"FETCH_SUCCESS": "TV-Gids bijgewerkt",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -247,6 +247,7 @@
|
||||
"LIVE_STREAM": "Transmisja na żywo",
|
||||
"TIMESHIFT_AVAILABLE": "Dostępne jest przesunięcie czasowe, kliknij, aby odtworzyć",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ups, EPG nie jest dostępne dla wybranej daty",
|
||||
"NO_GUIDE_HINT": "Spróbuj innej daty lub odśwież playlistę.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ups, EPG nie jest dostępne dla tego kanału.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Dodaj/zmień URL listy EPG w ustawieniach aplikacji.",
|
||||
"FETCH_EPG": "Pobieranie danych EPG...",
|
||||
@@ -261,7 +262,10 @@
|
||||
"LANGUAGE": "Język",
|
||||
"SHOW_PROGRAM_DETAILS": "Pokaż szczegóły programu"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane"
|
||||
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"REFRESH_XTREAM": "Atualizar playlist Xtream do servidor",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "Atualizar playlist Xtream",
|
||||
"MESSAGE": "Isso excluirá as categorias e streams armazenados localmente e reimportará tudo do servidor remoto. Seus favoritos e itens visualizados recentemente serão preservados. Deseja continuar?",
|
||||
"MESSAGE": "Isso excluirá o cache local do Xtream e reimportará categorias e streams do servidor remoto. Favoritos, itens visualizados recentemente, visibilidade de categorias ocultas e progresso de reprodução serão restaurados após uma reimportação bem-sucedida quando o conteúdo correspondente ainda existir. Se o servidor não puder ser alcançado, a restauração ficará pendente até uma atualização bem-sucedida posterior. Continuar?",
|
||||
"STARTED": "Atualização iniciada. Navegando para a playlist...",
|
||||
"ERROR": "Falha ao atualizar a playlist. Tente novamente."
|
||||
},
|
||||
@@ -266,6 +266,7 @@
|
||||
"LIVE_STREAM": "Transmissão ao vivo",
|
||||
"TIMESHIFT_AVAILABLE": "Timeshift está disponível, clique para reproduzir",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ooops, EPG não está disponível para a data selecionada",
|
||||
"NO_GUIDE_HINT": "Tente uma data diferente ou atualize a lista de reprodução.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Ooops, EPG não está disponível para este canal.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Adicione/altere a URL da lista de EPG nas configurações do aplicativo.",
|
||||
"FETCH_EPG": "Buscando dados do EPG...",
|
||||
@@ -280,7 +281,10 @@
|
||||
"LANGUAGE": "Idioma",
|
||||
"SHOW_PROGRAM_DETAILS": "Mostrar detalhes sobre este programa"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG foi buscado com sucesso"
|
||||
"FETCH_SUCCESS": "EPG foi buscado com sucesso",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"REFRESH_XTREAM": "Обновить Xtream плейлист с сервера",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "Обновить Xtream плейлист",
|
||||
"MESSAGE": "Это удалит локально кэшированные категории и потоки и повторно импортирует все с удаленного сервера. Ваши избранные и недавно просмотренные элементы будут сохранены. Вы уверены, что хотите продолжить?",
|
||||
"MESSAGE": "Это удалит локальный кэш Xtream и заново импортирует категории и потоки с удаленного сервера. Избранное, недавно просмотренные элементы, видимость скрытых категорий и прогресс воспроизведения будут восстановлены после успешного повторного импорта, если соответствующий контент все еще существует. Если сервер недоступен, восстановление останется в ожидании до следующего успешного обновления. Продолжить?",
|
||||
"STARTED": "Обновление начато. Переход к плейлисту...",
|
||||
"ERROR": "Не удалось обновить плейлист. Пожалуйста, попробуйте снова.",
|
||||
"IN_PROGRESS": "Плейлист обновляется...",
|
||||
@@ -346,6 +346,7 @@
|
||||
"LIVE_STREAM": "Прямая трансляция",
|
||||
"TIMESHIFT_AVAILABLE": "Доступна функция архива (timeshift). Нажмите на передачу для воспроизведения.",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Ой, программа на этот день, к сожалению, отсутствует.",
|
||||
"NO_GUIDE_HINT": "Попробуйте другую дату или обновите плейлист.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Программа для этого канала недоступна.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Попробуйте изменить или обновить ссылку на EPG в настройках приложения.",
|
||||
"FETCH_EPG": "Загружаю программу ТВ-передач (EPG)...",
|
||||
@@ -360,7 +361,10 @@
|
||||
"DESCRIPTION": "Описание",
|
||||
"LANGUAGE": "Язык",
|
||||
"SHOW_PROGRAM_DETAILS": "Показать подробности"
|
||||
}
|
||||
},
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"REFRESH_XTREAM": "Xtream oynatma listesini uzaktan yenile",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "Xtream Oynatma Listesini Yenile",
|
||||
"MESSAGE": "Bu işlem yerel olarak önbelleğe alınmış kategorileri ve yayınları silecek ve her şeyi uzaktan sunucudan yeniden içe aktaracaktır. Favorileriniz ve son izlenen öğeleriniz korunacaktır. Devam etmek istediğinizden emin misiniz?",
|
||||
"MESSAGE": "Bu işlem yerel Xtream önbelleğini siler ve kategoriler ile yayınları uzak sunucudan yeniden içe aktarır. Favoriler, son izlenen öğeler, gizli kategori görünürlüğü ve oynatma ilerlemesi, eşleşen içerik hâlâ varsa başarılı bir yeniden içe aktarmadan sonra geri yüklenir. Sunucuya ulaşılamazsa geri yükleme, daha sonraki başarılı bir yenilemeye kadar beklemede kalır. Devam edilsin mi?",
|
||||
"STARTED": "Yenileme başladı. Oynatma listesine gidiliyor...",
|
||||
"ERROR": "Oynatma listesi yenilenemedi. Lütfen tekrar deneyin."
|
||||
},
|
||||
@@ -309,6 +309,7 @@
|
||||
"LIVE_STREAM": "Canlı yayın",
|
||||
"TIMESHIFT_AVAILABLE": "Zaman kaydırma mevcut, tıklayarak oynat",
|
||||
"EPG_NOT_AVAILABLE_DATE": "Üzgünüz, seçilen tarih için EPG mevcut değil",
|
||||
"NO_GUIDE_HINT": "Başka bir tarih deneyin veya oynatma listesini yenileyin.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "Üzgünüz, bu kanal için EPG mevcut değil.",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Lütfen uygulamanın ayarlarında EPG listesi URL'sini ekleyin veya değiştirin.",
|
||||
"FETCH_EPG": "EPG verileri alınıyor...",
|
||||
@@ -335,7 +336,10 @@
|
||||
"TOTAL_CHANNELS": "Toplam kanal",
|
||||
"TOTAL_PROGRAMS": "Toplam program",
|
||||
"UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok",
|
||||
"RETRY": "Tekrar Dene"
|
||||
"RETRY": "Tekrar Dene",
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"LIVE_STREAM": "正在直播",
|
||||
"TIMESHIFT_AVAILABLE": "时移功能可用,点击播放",
|
||||
"EPG_NOT_AVAILABLE_DATE": "错误:电子节目单EPG在所选日期不可用",
|
||||
"NO_GUIDE_HINT": "尝试其他日期或刷新播放列表。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "错误:电子节目单EPG不适用于此频道",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "请添加/更改设置项中电子节目单的URL",
|
||||
"FETCH_EPG": "获取 EPG 数据...",
|
||||
@@ -214,7 +215,10 @@
|
||||
"DESCRIPTION": "描述",
|
||||
"LANGUAGE": "语言",
|
||||
"SHOW_PROGRAM_DETAILS": "显示有关此程序的详细信息"
|
||||
}
|
||||
},
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"REFRESH_XTREAM": "從遠端重新整理 Xtream 播放清單",
|
||||
"REFRESH_XTREAM_DIALOG": {
|
||||
"TITLE": "重新整理 Xtream 播放清單",
|
||||
"MESSAGE": "這將刪除本機快取的類別和串流,並從遠端伺服器重新匯入所有內容。您的最愛和最近觀看的項目將會保留。確定要繼續嗎?",
|
||||
"MESSAGE": "這將刪除本機 Xtream 快取,並從遠端伺服器重新匯入類別和串流。若相符內容仍然存在,最愛、最近觀看項目、隱藏類別可見性和播放進度會在成功重新匯入後還原。如果無法連線到伺服器,還原會保持待處理狀態,直到之後成功重新整理為止。要繼續嗎?",
|
||||
"STARTED": "已開始重新整理。正在導向播放清單...",
|
||||
"ERROR": "重新整理播放清單失敗。請重試。"
|
||||
},
|
||||
@@ -247,6 +247,7 @@
|
||||
"LIVE_STREAM": "現場直播",
|
||||
"TIMESHIFT_AVAILABLE": "時間平移功能可用,點選以播放",
|
||||
"EPG_NOT_AVAILABLE_DATE": "抱歉,選擇的日期上 EPG 無法使用",
|
||||
"NO_GUIDE_HINT": "嘗試其他日期或重新整理播放清單。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_TITLE": "抱歉,此頻道上的 EPG 無法使用。",
|
||||
"EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "請在應用程式設定中增加/更改 EPG 列表的URL。",
|
||||
"FETCH_EPG": "取得 EPG 資料...",
|
||||
@@ -260,7 +261,10 @@
|
||||
"DESCRIPTION": "說明",
|
||||
"LANGUAGE": "語言",
|
||||
"SHOW_PROGRAM_DETAILS": "顯示關於該節目的詳細資訊"
|
||||
}
|
||||
},
|
||||
"CURRENT_PROGRAM": "Current program",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel"
|
||||
},
|
||||
"LANGUAGES": {
|
||||
"ARABIC": "العربية",
|
||||
|
||||
@@ -80,6 +80,9 @@ html {
|
||||
transparent
|
||||
);
|
||||
|
||||
// Live / on-air accent (broadcast indicator)
|
||||
--app-live-color: #d12c2c;
|
||||
|
||||
// Content area
|
||||
--app-content-shadow: -2px 4px 12px rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -152,6 +155,9 @@ html {
|
||||
transparent
|
||||
);
|
||||
|
||||
// Live / on-air accent (broadcast indicator)
|
||||
--app-live-color: #ff8f8f;
|
||||
|
||||
// Content area
|
||||
--app-content-shadow: -4px 8px 20px rgba(0, 0, 0, 0.18);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@use '../../../libs/ui/components/src/lib/resizable/resizable.scss';
|
||||
@use './settings-theme';
|
||||
@use './cover-size';
|
||||
|
||||
// ─── Tailwind CSS ─────────────────────────────────────────────────────────────
|
||||
// Preflight is disabled in tailwind.config.js to avoid conflicts with
|
||||
@@ -27,6 +28,70 @@
|
||||
|
||||
// ─── Shared Components ────────────────────────────────────────────────────────
|
||||
|
||||
// Always-visible themed scrollbar. Apply to scroll containers that should
|
||||
// reserve gutter and not rely on macOS overlay scrollbars (which auto-fade).
|
||||
// Selector is doubled (.app-scrollbar.app-scrollbar) throughout so the rules
|
||||
// outrank both component-scoped overflow declarations and the global
|
||||
// `.dark-theme ::-webkit-scrollbar` block below.
|
||||
.app-scrollbar.app-scrollbar {
|
||||
// !important so the utility wins over component-scoped overflow rules
|
||||
// (Angular ViewEncapsulation adds an attribute selector that pushes
|
||||
// host specificity above the doubled class selector).
|
||||
overflow-y: scroll !important;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent)
|
||||
transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-muted-color) 70%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
min-height: 32px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--app-muted-color);
|
||||
background-clip: content-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared count-badge pill. Section headers (dashboard rails, content rails)
|
||||
// and dense list rows (category sidebars) share a single visual treatment so
|
||||
// "count" reads consistently across the app. Theme-aware via --app-muted-color.
|
||||
.app-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-muted-color, #8891a4) 18%,
|
||||
transparent
|
||||
);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
app-root.macos-platform .current-playlist {
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -94,6 +94,9 @@ declare global {
|
||||
skipped?: string[];
|
||||
}>;
|
||||
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
|
||||
getCurrentProgramsBatch: (
|
||||
channelIds: string[]
|
||||
) => Promise<Record<string, EpgProgram | null>>;
|
||||
getEpgChannelMetadata: (
|
||||
channelIds: string[]
|
||||
) => Promise<Record<string, EpgChannelMetadata | null>>;
|
||||
@@ -277,6 +280,9 @@ declare global {
|
||||
contentId: number,
|
||||
playlistId: string
|
||||
) => Promise<{ success: boolean }>;
|
||||
dbRemoveRecentItemsBatch: (
|
||||
items: { contentId: number; playlistId: string }[]
|
||||
) => Promise<{ success: boolean; count: number }>;
|
||||
dbGetContentByXtreamId: (
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
|
||||
@@ -126,6 +126,23 @@ The shared row should be reused instead of rebuilding channel markup per view.
|
||||
- Add bottom padding so the last program is not clipped
|
||||
- Current program card uses the same selection treatment as selected channels
|
||||
|
||||
### Collapsible Live EPG
|
||||
|
||||
- Live TV layouts with an internal player use `app-live-epg-panel` around the
|
||||
EPG content, including playlist-specific live pages and the global
|
||||
favorites/recent live tabs.
|
||||
- The live panel toolbar owns the current-program summary and live date
|
||||
navigation together, so `app-epg-list` hides its internal date navigator when
|
||||
projected inside that panel.
|
||||
- Collapsed state is shared across M3U, Xtream, and Stalker with
|
||||
`live-epg-panel-state`; missing or invalid values restore to expanded.
|
||||
- The collapsed panel is a slim current-program strip with a trailing progress
|
||||
line and an expand button. Date controls stay out of the collapsed strip.
|
||||
- Do not render the collapsed strip for external MPV/VLC playback; those
|
||||
layouts keep the full EPG-only panel.
|
||||
- Keep the EPG content mounted while collapsed so current-program state can
|
||||
continue updating.
|
||||
|
||||
### EPG Card
|
||||
|
||||
- Radius:
|
||||
|
||||
@@ -5,6 +5,7 @@ This document describes the M3U playlist module architecture, which handles trad
|
||||
## Overview
|
||||
|
||||
The M3U playlist module provides:
|
||||
|
||||
- Channel list display with virtual scrolling (90,000+ channels support)
|
||||
- EPG (Electronic Program Guide) integration
|
||||
- Favorites management with drag-and-drop reordering
|
||||
@@ -80,32 +81,32 @@ used by the groups view to remember which group titles the user has hidden.
|
||||
|
||||
### Actions
|
||||
|
||||
| Action Group | Actions | Purpose |
|
||||
|--------------|---------|---------|
|
||||
| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD |
|
||||
| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation |
|
||||
| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state |
|
||||
| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management |
|
||||
| **FilterActions** | `setSelectedFilters` | Playlist type filtering |
|
||||
| Action Group | Actions | Purpose |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD |
|
||||
| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation |
|
||||
| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state |
|
||||
| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management |
|
||||
| **FilterActions** | `setSelectedFilters` | Playlist type filtering |
|
||||
|
||||
### Key Selectors
|
||||
|
||||
```typescript
|
||||
// Channel selectors
|
||||
selectActive // Current playing channel
|
||||
selectChannelsLoading // Channel list loading flag
|
||||
selectChannels // All channels array
|
||||
selectFavorites // Favorite channel URLs
|
||||
selectActive; // Current playing channel
|
||||
selectChannelsLoading; // Channel list loading flag
|
||||
selectChannels; // All channels array
|
||||
selectFavorites; // Favorite channel URLs
|
||||
|
||||
// Playlist selectors
|
||||
selectAllPlaylistsMeta // All playlists
|
||||
selectActivePlaylistId // Selected playlist ID
|
||||
selectCurrentPlaylist // Active playlist object
|
||||
selectPlaylistTitle // Title with "Global favorites" fallback
|
||||
selectAllPlaylistsMeta; // All playlists
|
||||
selectActivePlaylistId; // Selected playlist ID
|
||||
selectCurrentPlaylist; // Active playlist object
|
||||
selectPlaylistTitle; // Title with "Global favorites" fallback
|
||||
|
||||
// EPG selectors
|
||||
selectIsEpgAvailable // EPG data available flag
|
||||
selectCurrentEpgProgram // Current playing program
|
||||
selectIsEpgAvailable; // EPG data available flag
|
||||
selectCurrentEpgProgram; // Current playing program
|
||||
```
|
||||
|
||||
## Channel List Container
|
||||
@@ -182,9 +183,9 @@ channel-list-container/
|
||||
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
|
||||
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
|
||||
- Empty playlists and empty search results are no longer conflated:
|
||||
- loading: skeletons
|
||||
- empty source: no channels in the playlist after loading completes
|
||||
- empty search: no matches within an already loaded playlist
|
||||
- loading: skeletons
|
||||
- empty source: no channels in the playlist after loading completes
|
||||
- empty search: no matches within an already loaded playlist
|
||||
|
||||
### Group Visibility Management
|
||||
|
||||
@@ -213,8 +214,8 @@ For performance optimization, channels are pre-enriched with EPG data:
|
||||
```typescript
|
||||
interface EnrichedChannel extends Channel {
|
||||
epgProgram: EpgProgram | null | undefined;
|
||||
logo: string; // Playlist tvg-logo first, XMLTV icon fallback second
|
||||
progressPercentage: number; // Pre-computed by parent
|
||||
logo: string; // Playlist tvg-logo first, XMLTV icon fallback second
|
||||
progressPercentage: number; // Pre-computed by parent
|
||||
}
|
||||
```
|
||||
|
||||
@@ -237,29 +238,32 @@ EPG lookup keys use the same precedence in both program and icon paths:
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
| Optimization | Implementation |
|
||||
|--------------|----------------|
|
||||
| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels |
|
||||
| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe |
|
||||
| **Debounced Search** | 300ms debounce on search input |
|
||||
| **Global Progress Tick** | Single 30s interval instead of per-item intervals |
|
||||
| **OnPush Change Detection** | All components use OnPush |
|
||||
| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time |
|
||||
| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal |
|
||||
| Optimization | Implementation |
|
||||
| ----------------------------- | --------------------------------------------------------- |
|
||||
| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels |
|
||||
| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe |
|
||||
| **Debounced Search** | 300ms debounce on search input |
|
||||
| **Global Progress Tick** | Single 30s interval instead of per-item intervals |
|
||||
| **OnPush Change Detection** | All components use OnPush |
|
||||
| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time |
|
||||
| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal |
|
||||
|
||||
### Tab Components
|
||||
|
||||
#### AllChannelsTabComponent
|
||||
|
||||
- **Inputs**: `channels`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds`
|
||||
- **Outputs**: `channelSelected`, `favoriteToggled`
|
||||
- **Features**: Search with 300ms debounce, virtual scrolling, no-results placeholder
|
||||
|
||||
#### GroupsTabComponent
|
||||
|
||||
- **Inputs**: Same as AllChannelsTab + `groupedChannels`
|
||||
- **Outputs**: `channelSelected`, `favoriteToggled`
|
||||
- **Features**: Expansion panels, infinite scroll with IntersectionObserver, lazy loading
|
||||
|
||||
#### FavoritesTabComponent
|
||||
|
||||
- **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||||
- **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered`
|
||||
- **Features**: Drag-and-drop reordering with CDK DragDrop
|
||||
@@ -277,10 +281,14 @@ class EpgService {
|
||||
getChannelPrograms(channelId: string): void;
|
||||
|
||||
// Batch fetch current programs
|
||||
getCurrentProgramsForChannels(channelIds: string[]): Observable<Map<string, EpgProgram>>;
|
||||
getCurrentProgramsForChannels(
|
||||
channelIds: string[]
|
||||
): Observable<Map<string, EpgProgram>>;
|
||||
|
||||
// Batch fetch XMLTV channel metadata for logo fallback
|
||||
getChannelMetadataForChannels(channelIds: string[]): Observable<Map<string, EpgChannelMetadata | null>>;
|
||||
getChannelMetadataForChannels(
|
||||
channelIds: string[]
|
||||
): Observable<Map<string, EpgChannelMetadata | null>>;
|
||||
|
||||
// Observables
|
||||
epgAvailable$: Observable<boolean>;
|
||||
@@ -290,27 +298,31 @@ class EpgService {
|
||||
|
||||
### EPG Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `EpgListComponent` | Timeline view for single channel |
|
||||
| `EpgListItemComponent` | Individual program in timeline |
|
||||
| `EpgItemDescriptionComponent` | Program details dialog |
|
||||
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
|
||||
| Component | Purpose |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| `EpgListComponent` | Timeline view for single channel |
|
||||
| `EpgListItemComponent` | Individual program in timeline |
|
||||
| `EpgItemDescriptionComponent` | Program details dialog |
|
||||
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
|
||||
|
||||
## Video Player
|
||||
|
||||
**Location**: `libs/playlist/m3u/feature-player/src/lib/video-player/`
|
||||
|
||||
### Supported Players
|
||||
|
||||
- **ArtPlayer** (default) - Modern player with plugins
|
||||
- **Video.js** - Fallback with HLS support
|
||||
- **HTML5** - Basic video element
|
||||
- **Audio** - For radio streams
|
||||
|
||||
### Player Features
|
||||
|
||||
- Channel navigation (prev/next)
|
||||
- Favorites toggle
|
||||
- EPG sidebar
|
||||
- Collapsible inline EPG panel for internal players, persisted through the
|
||||
shared `live-epg-panel-state` preference
|
||||
- Multi-EPG modal view
|
||||
- Channel info overlay
|
||||
- External player support (MPV, VLC) in Electron
|
||||
@@ -324,16 +336,16 @@ class EpgService {
|
||||
- M3U catch-up support is resolved in `m3u-utils` from channel metadata and
|
||||
the archived program start time.
|
||||
- Supported replay precedence:
|
||||
1. `catchup.source` if it is an HTTP(S) URL. IPTVNator rewrites or appends
|
||||
standard `utc` and `lutc` query params on that URL.
|
||||
2. Legacy same-stream shift playback when `catchup.type === 'shift'`. In
|
||||
that case IPTVNator rewrites or appends `utc` and `lutc` on `channel.url`.
|
||||
3. Legacy same-stream shift fallback when no explicit catch-up mode is
|
||||
declared, archive-day metadata exists (`tvg.rec`, `timeshift`, or
|
||||
`catchup.days`), and `channel.url` itself is an HTTP(S) stream URL. This
|
||||
covers providers that only advertise archive retention such as
|
||||
`tvg-rec="7"` but still expect standard `utc` and `lutc` query params on
|
||||
the live URL.
|
||||
1. `catchup.source` if it is an HTTP(S) URL. IPTVNator rewrites or appends
|
||||
standard `utc` and `lutc` query params on that URL.
|
||||
2. Legacy same-stream shift playback when `catchup.type === 'shift'`. In
|
||||
that case IPTVNator rewrites or appends `utc` and `lutc` on `channel.url`.
|
||||
3. Legacy same-stream shift fallback when no explicit catch-up mode is
|
||||
declared, archive-day metadata exists (`tvg.rec`, `timeshift`, or
|
||||
`catchup.days`), and `channel.url` itself is an HTTP(S) stream URL. This
|
||||
covers providers that only advertise archive retention such as
|
||||
`tvg-rec="7"` but still expect standard `utc` and `lutc` query params on
|
||||
the live URL.
|
||||
- `tvg.rec`, `timeshift`, and `catchup.days` still define the archive window
|
||||
shown in the EPG, but replay remains unavailable when the provider declares a
|
||||
different explicit catch-up scheme that IPTVNator does not understand or when
|
||||
@@ -346,6 +358,7 @@ class EpgService {
|
||||
## Interfaces
|
||||
|
||||
### Channel Interface
|
||||
|
||||
```typescript
|
||||
interface Channel {
|
||||
id: string;
|
||||
@@ -353,7 +366,7 @@ interface Channel {
|
||||
name: string;
|
||||
group: { title: string };
|
||||
tvg: {
|
||||
id: string; // For EPG matching
|
||||
id: string; // For EPG matching
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
@@ -384,11 +397,12 @@ interface PlaylistState {
|
||||
```
|
||||
|
||||
### EpgProgram Interface
|
||||
|
||||
```typescript
|
||||
interface EpgProgram {
|
||||
start: string; // ISO string
|
||||
stop: string; // ISO string
|
||||
channel: string; // TVG ID
|
||||
start: string; // ISO string
|
||||
stop: string; // ISO string
|
||||
channel: string; // TVG ID
|
||||
title: string;
|
||||
desc: string | null;
|
||||
category: string | null;
|
||||
@@ -408,17 +422,20 @@ interface EpgProgram {
|
||||
## Adding New Features
|
||||
|
||||
### To add a new tab to channel list:
|
||||
|
||||
1. Create component in `channel-list-container/new-tab/`
|
||||
2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
|
||||
3. Emit `channelSelected` output
|
||||
4. Add to parent template and imports
|
||||
|
||||
### To add EPG-related features:
|
||||
|
||||
1. Use `EpgService` for data fetching
|
||||
2. Subscribe to `channelEpgMap` signal for current programs
|
||||
3. Dispatch `EpgActions` for state updates
|
||||
|
||||
### To modify favorites behavior:
|
||||
|
||||
1. Dispatch `FavoritesActions.updateFavorites` for toggle
|
||||
2. Dispatch `FavoritesActions.setFavorites` for reordering
|
||||
3. Effects automatically persist to database
|
||||
@@ -78,21 +78,21 @@ GET load.php?type=itv&action=get_short_epg&ch_id={channel_id}&size={n}&JsHttpReq
|
||||
|
||||
```json
|
||||
{
|
||||
"js": {
|
||||
"data": [
|
||||
{
|
||||
"id": "123",
|
||||
"ch_id": "45",
|
||||
"name": "Program Title",
|
||||
"descr": "Program description",
|
||||
"time": "2025-01-15 14:00:00",
|
||||
"time_to": "2025-01-15 14:30:00",
|
||||
"duration": "1800",
|
||||
"start_timestamp": "1736949600",
|
||||
"stop_timestamp": "1736951400"
|
||||
}
|
||||
]
|
||||
}
|
||||
"js": {
|
||||
"data": [
|
||||
{
|
||||
"id": "123",
|
||||
"ch_id": "45",
|
||||
"name": "Program Title",
|
||||
"descr": "Program description",
|
||||
"time": "2025-01-15 14:00:00",
|
||||
"time_to": "2025-01-15 14:30:00",
|
||||
"duration": "1800",
|
||||
"start_timestamp": "1736949600",
|
||||
"stop_timestamp": "1736951400"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -120,21 +120,21 @@ GET load.php?type=itv&action=get_epg_info&period={hours}&JsHttpRequest=1-xml
|
||||
|
||||
```json
|
||||
{
|
||||
"js": {
|
||||
"data": {
|
||||
"45": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Program Title",
|
||||
"descr": "Program description",
|
||||
"time": "2025-01-15 14:00:00",
|
||||
"time_to": "2025-01-15 16:00:00",
|
||||
"start_timestamp": "1736949600",
|
||||
"stop_timestamp": "1736956800"
|
||||
"js": {
|
||||
"data": {
|
||||
"45": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Program Title",
|
||||
"descr": "Program description",
|
||||
"time": "2025-01-15 14:00:00",
|
||||
"time_to": "2025-01-15 16:00:00",
|
||||
"start_timestamp": "1736949600",
|
||||
"stop_timestamp": "1736956800"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -153,16 +153,16 @@ The short EPG path now exists only for the active-panel fallback flow.
|
||||
|
||||
Key mapped fields:
|
||||
|
||||
| Stalker field | `EpgItem` field |
|
||||
| --- | --- |
|
||||
| `id` | `id` |
|
||||
| `ch_id` | `channel_id` |
|
||||
| `name` | `title` |
|
||||
| `descr` | `description` |
|
||||
| `time` | `start` |
|
||||
| `time_to` | `end`, `stop` |
|
||||
| Stalker field | `EpgItem` field |
|
||||
| ----------------- | ----------------- |
|
||||
| `id` | `id` |
|
||||
| `ch_id` | `channel_id` |
|
||||
| `name` | `title` |
|
||||
| `descr` | `description` |
|
||||
| `time` | `start` |
|
||||
| `time_to` | `end`, `stop` |
|
||||
| `start_timestamp` | `start_timestamp` |
|
||||
| `stop_timestamp` | `stop_timestamp` |
|
||||
| `stop_timestamp` | `stop_timestamp` |
|
||||
|
||||
### Active panel data (`get_epg_info` / fallback) → `EpgProgram`
|
||||
|
||||
@@ -180,13 +180,13 @@ Normalization rules:
|
||||
|
||||
### Key files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading |
|
||||
| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator |
|
||||
| File | Purpose |
|
||||
| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template |
|
||||
| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading |
|
||||
| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator |
|
||||
|
||||
### Store API
|
||||
|
||||
@@ -215,6 +215,10 @@ playlists.
|
||||
`get_short_epg`
|
||||
|
||||
The active panel no longer uses local EPG pagination or a "Load more" button.
|
||||
When Stalker live TV is playing through an internal player, the active panel is
|
||||
wrapped in the shared collapsible live EPG panel. The collapsed/expanded state
|
||||
uses the shared `live-epg-panel-state` preference and is only applied after a
|
||||
stream URL has been resolved; external playback keeps the full EPG-only panel.
|
||||
|
||||
### Channel row preview flow
|
||||
|
||||
@@ -238,10 +242,10 @@ derived locally from `bulkItvEpgByChannel`:
|
||||
|
||||
EPG requests follow the standard Stalker request path:
|
||||
|
||||
| Portal type | Auth path |
|
||||
| --- | --- |
|
||||
| Full Stalker portal | `StalkerSessionService.makeAuthenticatedRequest()` |
|
||||
| Simple Stalker portal | generic IPC request path via Electron |
|
||||
| Portal type | Auth path |
|
||||
| --------------------- | -------------------------------------------------- |
|
||||
| Full Stalker portal | `StalkerSessionService.makeAuthenticatedRequest()` |
|
||||
| Simple Stalker portal | generic IPC request path via Electron |
|
||||
|
||||
No EPG-specific backend transport was needed; the Electron Stalker request
|
||||
handler forwards portal params directly.
|
||||
|
||||
@@ -54,7 +54,12 @@ Provider route integration:
|
||||
3. Xtream and Stalker parent routes attach route-scoped session providers that
|
||||
bootstrap the active playlist, sync provider section state, and clean up
|
||||
provider-local state when the route is destroyed.
|
||||
4. Workspace routes no longer rely on nested provider shell components for
|
||||
4. Xtream route bootstrap is DB-first for already imported Electron playlists:
|
||||
if the requested section has persisted categories and content, the route
|
||||
hydrates from SQLite even when the portal status probe reports unavailable,
|
||||
expired, or inactive. Fresh/no-cache Xtream routes still use the status probe
|
||||
to block remote imports before the loading overlay starts.
|
||||
5. Workspace routes no longer rely on nested provider shell components for
|
||||
hidden local chrome.
|
||||
|
||||
## Shell Structure
|
||||
@@ -126,6 +131,18 @@ Command palette behavior is shell-owned but view-extensible:
|
||||
metadata through `WorkspaceHeaderContextService`.
|
||||
5. Filtering matches command labels, descriptions, and keywords, and keyboard
|
||||
selection always lands on the first enabled command.
|
||||
6. A "Recently used" section is rendered above the standard groups when the
|
||||
query is empty and at least one stored id resolves to a visible+enabled
|
||||
command; ids are persisted via `RecentCommandsService` (capped at 5,
|
||||
stored at `STORE_KEY.RecentCommands`). Storage is **not** pruned by route
|
||||
visibility — a navigation command like `Open sources` is invisible while
|
||||
the user is on `/workspace/sources` but the id stays in storage so it
|
||||
reappears in the recent section after navigating away.
|
||||
7. Five "Switch player to X" commands are registered globally by
|
||||
`WorkspacePlayerCommandsContributor`. The MPV/VLC entries are visible only
|
||||
in Electron, and the entry matching the current `SettingsStore.player()`
|
||||
value is disabled. The new player setting applies to the next playback
|
||||
session; an existing stream is not re-mounted.
|
||||
|
||||
## Maintenance Guidance
|
||||
|
||||
|
||||
Vendored
+6
@@ -87,6 +87,9 @@ declare global {
|
||||
urls: string[]
|
||||
) => Promise<{ success: boolean; message?: string }>;
|
||||
getChannelPrograms: (channelId: string) => Promise<any>;
|
||||
getCurrentProgramsBatch: (
|
||||
channelIds: string[]
|
||||
) => Promise<Record<string, any>>;
|
||||
getEpgChannels: () => Promise<any>;
|
||||
getEpgChannelsByRange: (
|
||||
skip: number,
|
||||
@@ -307,6 +310,9 @@ declare global {
|
||||
contentId: number,
|
||||
playlistId: string
|
||||
) => Promise<{ success: boolean }>;
|
||||
dbRemoveRecentItemsBatch: (
|
||||
items: { contentId: number; playlistId: string }[]
|
||||
) => Promise<{ success: boolean; count: number }>;
|
||||
dbGetContentByXtreamId: (
|
||||
xtreamId: number,
|
||||
playlistId: string,
|
||||
|
||||
@@ -205,16 +205,45 @@ export class EpgService {
|
||||
return of(resultMap);
|
||||
}
|
||||
|
||||
// Fetch uncached channels with timeout and error handling per request
|
||||
// Single batched IPC + SQL query when the backend supports it.
|
||||
// Replaces the legacy N+1 forkJoin where each channel fired its own
|
||||
// GET_CHANNEL_PROGRAMS round-trip.
|
||||
const batchApi = window.electron as Window['electron'] & {
|
||||
getCurrentProgramsBatch?: (
|
||||
channelIds: string[]
|
||||
) => Promise<Record<string, EpgProgram | null>>;
|
||||
};
|
||||
if (typeof batchApi?.getCurrentProgramsBatch === 'function') {
|
||||
return from(batchApi.getCurrentProgramsBatch(channelsToFetch)).pipe(
|
||||
timeout(5000),
|
||||
map((batchResult) => {
|
||||
const cacheTimestamp = Date.now();
|
||||
channelsToFetch.forEach((channelId) => {
|
||||
const program = batchResult?.[channelId] ?? null;
|
||||
resultMap.set(channelId, program);
|
||||
this.programCache.set(channelId, {
|
||||
program,
|
||||
timestamp: cacheTimestamp,
|
||||
});
|
||||
});
|
||||
return resultMap;
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.error('EPG batch current programs error:', err);
|
||||
return of(resultMap);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for older preload bundles without the batch endpoint.
|
||||
const fetchObservables = channelsToFetch.map((channelId) =>
|
||||
this.getCurrentProgramForChannel(channelId).pipe(
|
||||
timeout(5000), // 5 second timeout per request
|
||||
timeout(5000),
|
||||
map((program) => ({ channelId, program })),
|
||||
catchError(() => of({ channelId, program: null }))
|
||||
)
|
||||
);
|
||||
|
||||
// Combine all fetches using forkJoin
|
||||
return forkJoin(fetchObservables).pipe(
|
||||
map((results) => {
|
||||
results.forEach((result) => {
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
EMPTY,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
mergeMap,
|
||||
switchMap,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
@@ -277,12 +279,14 @@ export class PlaylistEffects {
|
||||
parsePlaylist$ = createEffect(() => {
|
||||
return this.actions$.pipe(
|
||||
ofType(PlaylistActions.parsePlaylist),
|
||||
map((action) =>
|
||||
this.playlistsService.handlePlaylistParsing(
|
||||
action.uploadType,
|
||||
action.playlist,
|
||||
action.title,
|
||||
action.path
|
||||
mergeMap((action) =>
|
||||
from(
|
||||
this.playlistsService.handlePlaylistParsing(
|
||||
action.uploadType,
|
||||
action.playlist,
|
||||
action.title,
|
||||
action.path
|
||||
)
|
||||
)
|
||||
),
|
||||
map((playlist) =>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'playlist-import-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/playlist/import/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:playlist-import", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/playlist/import/feature/jest.config.ts",
|
||||
"tsConfig": "libs/playlist/import/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
+103
-68
@@ -4,7 +4,11 @@
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="apd-segmented" role="tablist" aria-label="Playlist category">
|
||||
<div
|
||||
class="apd-segmented"
|
||||
role="tablist"
|
||||
aria-label="Playlist category"
|
||||
>
|
||||
@for (opt of categoryOptions; track opt.value) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -65,81 +69,112 @@
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button type="button" (click)="closeDialog()">
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
<mat-dialog-actions class="apd-actions">
|
||||
<div class="apd-actions__reset">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="isClearDisabled()"
|
||||
(click)="clearCurrentForm()"
|
||||
>
|
||||
{{ 'CLEAR' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@switch (playlistType()) {
|
||||
@case ('url') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!urlUpload()?.form?.valid"
|
||||
(click)="submitUrlPlaylist()"
|
||||
>
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('file') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!fileUpload()?.selectedFile()"
|
||||
(click)="fileUpload()?.confirm()"
|
||||
>
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('text') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!textImport()?.textForm?.valid"
|
||||
(click)="textImport()?.textAdded?.emit(textImport()?.textForm?.value?.text)"
|
||||
>
|
||||
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('xtream') {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="!xtreamImport()?.form?.valid || xtreamImport()?.isTestingConnection"
|
||||
(click)="xtreamImport()?.testConnection()"
|
||||
>
|
||||
{{ xtreamImport()?.isTestingConnection ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!xtreamImport()?.form?.valid"
|
||||
(click)="xtreamImport()?.addPlaylist()"
|
||||
>
|
||||
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('stalker') {
|
||||
@if (stalkerImport()?.isLoading()) {
|
||||
<button mat-flat-button color="accent" type="button" disabled>
|
||||
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}...
|
||||
</button>
|
||||
} @else {
|
||||
<div class="apd-actions__decision">
|
||||
<button mat-button type="button" (click)="closeDialog()">
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
|
||||
@switch (playlistType()) {
|
||||
@case ('url') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!stalkerImport()?.form?.valid"
|
||||
(click)="stalkerImport()?.addPlaylist()"
|
||||
[disabled]="!urlUpload()?.form?.valid"
|
||||
(click)="submitUrlPlaylist()"
|
||||
>
|
||||
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('file') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!fileUpload()?.selectedFile()"
|
||||
(click)="fileUpload()?.confirm()"
|
||||
>
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('text') {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!textImport()?.textForm?.valid"
|
||||
(click)="
|
||||
textImport()?.textAdded?.emit(
|
||||
textImport()?.textForm?.value?.text
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('xtream') {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!xtreamImport()?.form?.valid ||
|
||||
xtreamImport()?.isTestingConnection
|
||||
"
|
||||
(click)="xtreamImport()?.testConnection()"
|
||||
>
|
||||
{{
|
||||
xtreamImport()?.isTestingConnection
|
||||
? 'Testing...'
|
||||
: 'Test Connection'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!xtreamImport()?.form?.valid"
|
||||
(click)="xtreamImport()?.addPlaylist()"
|
||||
>
|
||||
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('stalker') {
|
||||
@if (stalkerImport()?.isLoading()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.VALIDATING' | translate
|
||||
}}...
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
[disabled]="!stalkerImport()?.form?.valid"
|
||||
(click)="stalkerImport()?.addPlaylist()"
|
||||
>
|
||||
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
+57
-4
@@ -5,7 +5,11 @@
|
||||
--apd-muted: var(--app-muted-color);
|
||||
--apd-surface-raised: var(--app-widget-header-bg);
|
||||
--apd-accent: var(--app-selection-color);
|
||||
--apd-segment-track: color-mix(in srgb, var(--app-shell-bg) 70%, transparent);
|
||||
--apd-segment-track: color-mix(
|
||||
in srgb,
|
||||
var(--app-shell-bg) 70%,
|
||||
transparent
|
||||
);
|
||||
--apd-segment-active: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg) 70%,
|
||||
@@ -25,7 +29,11 @@
|
||||
margin-bottom: 14px;
|
||||
|
||||
&--sub {
|
||||
background: color-mix(in srgb, var(--apd-segment-track) 85%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-segment-track) 85%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,17 +105,62 @@
|
||||
margin: 6px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--apd-surface-raised) 50%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--apd-surface-raised) 50%,
|
||||
transparent
|
||||
);
|
||||
color: var(--apd-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.apd-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
|
||||
&__reset,
|
||||
&__decision {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__decision {
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.apd-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
&__reset,
|
||||
&__decision {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__decision {
|
||||
justify-content: flex-end;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Light-theme segmented control tweak ────────────────────────────────────
|
||||
// `.dark-theme` is on <body>, so target light mode via `body:not(...)`.
|
||||
body:not(.dark-theme) .apd {
|
||||
--apd-segment-track: color-mix(in srgb, var(--app-content-bg) 70%, transparent);
|
||||
--apd-segment-track: color-mix(
|
||||
in srgb,
|
||||
var(--app-content-bg) 70%,
|
||||
transparent
|
||||
);
|
||||
--apd-segment-active: #ffffff;
|
||||
|
||||
.apd-segment.active {
|
||||
|
||||
+78
-15
@@ -6,6 +6,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { DataService } from 'services';
|
||||
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
|
||||
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
|
||||
import { AddPlaylistDialogComponent } from './add-playlist-dialog.component';
|
||||
|
||||
describe('AddPlaylistDialogComponent', () => {
|
||||
@@ -106,27 +107,89 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
expect(dialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('strips playlist extensions from uploaded file titles', () => {
|
||||
component.handlePlaylist({
|
||||
uploadEvent: {
|
||||
target: {
|
||||
result: '#EXTM3U',
|
||||
},
|
||||
} as unknown as Event,
|
||||
file: {
|
||||
name: 'Local Source.m3u',
|
||||
path: '/tmp/Local Source.m3u',
|
||||
} as File,
|
||||
});
|
||||
it('dispatches imported text and closes the dialog', () => {
|
||||
component.uploadAsText('#EXTM3U');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.parsePlaylist({
|
||||
uploadType: 'FILE',
|
||||
uploadType: 'TEXT',
|
||||
playlist: '#EXTM3U',
|
||||
title: 'Local Source',
|
||||
path: '/tmp/Local Source.m3u',
|
||||
title: 'HOME.IMPORTED_AS_TEXT',
|
||||
})
|
||||
);
|
||||
expect(dialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the dialog after a successful file import', () => {
|
||||
component.onFileImported();
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
type: 'url',
|
||||
childAccessor: 'urlUpload',
|
||||
clearMethod: 'clearForm',
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
childAccessor: 'fileUpload',
|
||||
clearMethod: 'clearSelection',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
childAccessor: 'textImport',
|
||||
clearMethod: 'clearForm',
|
||||
},
|
||||
{
|
||||
type: 'xtream',
|
||||
childAccessor: 'xtreamImport',
|
||||
clearMethod: 'clearForm',
|
||||
},
|
||||
{
|
||||
type: 'stalker',
|
||||
childAccessor: 'stalkerImport',
|
||||
clearMethod: 'clearForm',
|
||||
},
|
||||
] as const)(
|
||||
'clears the current $type import surface',
|
||||
({ type, childAccessor, clearMethod }) => {
|
||||
const clear = jest.fn();
|
||||
(component as unknown as Record<string, jest.Mock>)[childAccessor] =
|
||||
jest.fn(() => ({
|
||||
[clearMethod]: clear,
|
||||
}));
|
||||
selectType(type);
|
||||
|
||||
component.clearCurrentForm();
|
||||
|
||||
expect(clear).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('disables clear when a file upload has no selection', () => {
|
||||
(component as { fileUpload: jest.Mock }).fileUpload = jest.fn(() => ({
|
||||
isImporting: () => false,
|
||||
selectedFile: () => null,
|
||||
}));
|
||||
selectType('file');
|
||||
|
||||
expect(component.isClearDisabled()).toBeTruthy();
|
||||
});
|
||||
|
||||
function selectType(type: PlaylistType): void {
|
||||
if (type === 'xtream') {
|
||||
component.category.set('xtream');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'stalker') {
|
||||
component.category.set('stalker');
|
||||
return;
|
||||
}
|
||||
|
||||
component.category.set('m3u');
|
||||
component.m3uSubType.set(type);
|
||||
}
|
||||
});
|
||||
+42
-5
@@ -16,10 +16,6 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
|
||||
import {
|
||||
M3uSubType,
|
||||
PlaylistCategory,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { DataService } from 'services';
|
||||
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
|
||||
@@ -29,6 +25,9 @@ import { TextImportComponent } from '../text-import/text-import.component';
|
||||
import { UrlUploadComponent } from '../url-upload/url-upload.component';
|
||||
import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component';
|
||||
|
||||
type PlaylistCategory = 'm3u' | Extract<PlaylistType, 'xtream' | 'stalker'>;
|
||||
type M3uSubType = Extract<PlaylistType, 'url' | 'file' | 'text'>;
|
||||
|
||||
interface CategoryOption {
|
||||
value: PlaylistCategory;
|
||||
label: string;
|
||||
@@ -139,7 +138,9 @@ export class AddPlaylistDialogComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlistName = this.normalizeOptionalValue(formValue?.playlistName);
|
||||
const playlistName = this.normalizeOptionalValue(
|
||||
formValue?.playlistName
|
||||
);
|
||||
|
||||
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
|
||||
url: playlistUrl,
|
||||
@@ -163,6 +164,42 @@ export class AddPlaylistDialogComponent {
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
clearCurrentForm(): void {
|
||||
switch (this.playlistType()) {
|
||||
case 'url':
|
||||
this.urlUpload()?.clearForm();
|
||||
break;
|
||||
case 'file':
|
||||
this.fileUpload()?.clearSelection();
|
||||
break;
|
||||
case 'text':
|
||||
this.textImport()?.clearForm();
|
||||
break;
|
||||
case 'xtream':
|
||||
this.xtreamImport()?.clearForm();
|
||||
break;
|
||||
case 'stalker':
|
||||
this.stalkerImport()?.clearForm();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
isClearDisabled(): boolean {
|
||||
switch (this.playlistType()) {
|
||||
case 'file':
|
||||
return (
|
||||
!this.fileUpload()?.selectedFile() ||
|
||||
!!this.fileUpload()?.isImporting()
|
||||
);
|
||||
case 'xtream':
|
||||
return !!this.xtreamImport()?.isTestingConnection;
|
||||
case 'stalker':
|
||||
return !!this.stalkerImport()?.isLoading();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
closeDialog(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
+18
@@ -79,6 +79,24 @@ export class StalkerPortalImportComponent {
|
||||
|
||||
readonly isLoading = signal(false);
|
||||
|
||||
clearForm(): void {
|
||||
this.form.reset({
|
||||
_id: uuid(),
|
||||
title: '',
|
||||
macAddress: '',
|
||||
serialNumber: '',
|
||||
deviceId1: '',
|
||||
deviceId2: '',
|
||||
signature1: '',
|
||||
signature2: '',
|
||||
password: '',
|
||||
username: '',
|
||||
portalUrl: '',
|
||||
importDate: new Date().toISOString(),
|
||||
userAgent: '',
|
||||
});
|
||||
}
|
||||
|
||||
async addPlaylist() {
|
||||
if (!this.form.valid || this.isLoading()) {
|
||||
return;
|
||||
|
||||
@@ -31,4 +31,18 @@ describe('TextImportComponent', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clears the text import form', () => {
|
||||
component.textForm.setValue({
|
||||
text: '#EXTM3U',
|
||||
});
|
||||
component.textForm.markAsDirty();
|
||||
|
||||
component.clearForm();
|
||||
|
||||
expect(component.textForm.value).toEqual({
|
||||
text: '',
|
||||
});
|
||||
expect(component.textForm.pristine).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -20,4 +20,10 @@ export class TextImportComponent {
|
||||
textForm = new FormGroup({
|
||||
text: new FormControl('', Validators.required),
|
||||
});
|
||||
|
||||
clearForm(): void {
|
||||
this.textForm.reset({
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,20 @@ describe('UrlUploadComponent', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.form.valid).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clears the url playlist form', () => {
|
||||
component.form.setValue({
|
||||
playlistName: 'News',
|
||||
playlistUrl: 'http://example.org/playlist.m3u',
|
||||
});
|
||||
component.form.markAsDirty();
|
||||
|
||||
component.clearForm();
|
||||
|
||||
expect(component.form.getRawValue()).toEqual({
|
||||
playlistName: '',
|
||||
playlistUrl: '',
|
||||
});
|
||||
expect(component.form.pristine).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -39,4 +39,11 @@ export class UrlUploadComponent implements OnInit {
|
||||
playlistName: [''],
|
||||
});
|
||||
}
|
||||
|
||||
clearForm(): void {
|
||||
this.form.reset({
|
||||
playlistName: '',
|
||||
playlistUrl: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -61,7 +61,6 @@ import { v4 as uuid } from 'uuid';
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
`,
|
||||
],
|
||||
})
|
||||
@@ -98,11 +97,15 @@ export class XtreamCodeImportComponent {
|
||||
}`;
|
||||
|
||||
try {
|
||||
// User-initiated connection test — bypass the shared cache so the
|
||||
// result reflects the portal's current state, not whatever was
|
||||
// cached up to 30 s ago by another component.
|
||||
this.connectionStatus =
|
||||
await this.portalStatusService.checkPortalStatus(
|
||||
serverUrl,
|
||||
this.form.value.username as string,
|
||||
this.form.value.password as string
|
||||
this.form.value.password as string,
|
||||
{ skipCache: true }
|
||||
);
|
||||
} finally {
|
||||
this.isTestingConnection = false;
|
||||
@@ -121,7 +124,7 @@ export class XtreamCodeImportComponent {
|
||||
return this.portalStatusService.getStatusIcon(this.connectionStatus);
|
||||
}
|
||||
|
||||
clearForm() {
|
||||
clearForm(): void {
|
||||
this.form.reset({
|
||||
_id: uuid(),
|
||||
title: '',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user