diff --git a/.plans/2026-04-28-live-epg-panel-collapse.md b/.plans/2026-04-28-live-epg-panel-collapse.md new file mode 100644 index 000000000..2333eb471 --- /dev/null +++ b/.plans/2026-04-28-live-epg-panel-collapse.md @@ -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`. diff --git a/.plans/2026-04-28-live-epg-unified-toolbar.md b/.plans/2026-04-28-live-epg-unified-toolbar.md new file mode 100644 index 000000000..c0b45efff --- /dev/null +++ b/.plans/2026-04-28-live-epg-unified-toolbar.md @@ -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. diff --git a/.plans/2026-05-01-xtream-type-search.md b/.plans/2026-05-01-xtream-type-search.md new file mode 100644 index 000000000..9a2c7181a --- /dev/null +++ b/.plans/2026-05-01-xtream-type-search.md @@ -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`. diff --git a/.plans/2026-05-02-embedded-mpv-merge-conflicts.md b/.plans/2026-05-02-embedded-mpv-merge-conflicts.md new file mode 100644 index 000000000..bd6881e48 --- /dev/null +++ b/.plans/2026-05-02-embedded-mpv-merge-conflicts.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. diff --git a/AGENTS.md b/AGENTS.md index d261cde34..eb95e0745 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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` diff --git a/CLAUDE.md b/CLAUDE.md index e8f75d755..e4ce53b82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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 : -# Example: nx run web:build -# Example: nx run electron-backend:serve +pnpm nx run : +# 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 diff --git a/README.md b/README.md index 46165f01b..80efa7c39 100644 --- a/README.md +++ b/README.md @@ -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](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#contributors) diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 000000000..0c326c012 --- /dev/null +++ b/TRADEMARK.md @@ -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. diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 007a473f7..f1d23c113 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -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$/); diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index b277325c8..054a92abb 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -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 { await page.getByRole('button', { name: 'Yes' }).click(); } -async function closeUnifiedLiveDetail(page: Page): Promise { - 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 { + 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 { @@ -634,14 +653,6 @@ async function playFirstSeriesEpisode(page: Page): Promise { await episodeCard.click(); } -async function removeLiveRecentItem(page: Page, title: string): Promise { - 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 { diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 59876cc51..000d74894 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -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[0]) { +async function visibleProgramTitles( + page: Parameters[0] +) { return page .locator('app-epg-list .program-title') .allInnerTexts() .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } -async function getProgressWidthPercent(row: ReturnType) { +async function expectCurrentProgram( + page: Parameters[0], + currentProgram: XtreamNormalizedEpgListing, + timeZone: string +): Promise { + 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 +) { const style = await row.locator('.epg-progress-fill').getAttribute('style'); const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0'; return Number.parseFloat(width); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 90b493b89..72d636d72 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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, diff --git a/apps/electron-backend/src/app/database/connection-schema.spec.ts b/apps/electron-backend/src/app/database/connection-schema.spec.ts new file mode 100644 index 000000000..0535311e2 --- /dev/null +++ b/apps/electron-backend/src/app/database/connection-schema.spec.ts @@ -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' + ); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index f09809da8..69314a23c 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -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(); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index ed38fecf9..3853cec71 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -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 }; diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index fb0ab1f90..3c6db8eeb 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -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 at compile time. + const updateFavoritePosition = db + .update(schema.favorites) + .set({ position: sql`${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, { diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index bd9e4cdef..b8cc19493 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -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 => 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, diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index 854153ca6..04b6fea49 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -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 }; +} diff --git a/apps/electron-backend/src/app/database/operations/xtream.operations.ts b/apps/electron-backend/src/app/database/operations/xtream.operations.ts index 7b51ec8ee..cc4980945 100644 --- a/apps/electron-backend/src/app/database/operations/xtream.operations.ts +++ b/apps/electron-backend/src/app/database/operations/xtream.operations.ts @@ -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', diff --git a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts index 9a871f2f4..041989d65 100644 --- a/apps/electron-backend/src/app/events/database/recently-viewed.events.ts +++ b/apps/electron-backend/src/app/events/database/recently-viewed.events.ts @@ -35,3 +35,10 @@ handleWorkerRequest( playlistId, }) ); + +handleWorkerRequest( + 'DB_REMOVE_RECENT_ITEMS_BATCH', + (items: { contentId: number; playlistId: string }[]) => ({ + items, + }) +); diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index ee8000e6d..4a7e31c91 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -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> { + const result: Record = {}; + 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 */ diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index f8fe3a9f3..e7a86da44 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -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', diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index 8e6d79d78..de81d9781 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -80,6 +80,10 @@ export async function getWorkerDatabase(): Promise { 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()) { diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index c5b53c84b..4649e5167 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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; diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 57e6e543f..7747bb1b7 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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 ({ diff --git a/apps/web/src/_cover-size.scss b/apps/web/src/_cover-size.scss new file mode 100644 index 000000000..6a45242b0 --- /dev/null +++ b/apps/web/src/_cover-size.scss @@ -0,0 +1,32 @@ +// Cover size tokens — consumed by all cover/poster grids and rails. +// Toggled via [data-cover-size] attribute on , 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; + } +} diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 7ffaba87f..764a6c31f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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) { diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 49f18665b..ba11259aa 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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', diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 900553481..fa4bb7078 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -2,6 +2,7 @@ describe('app routes', () => { let workspaceChildren: Array<{ canActivate?: unknown[]; data?: Record; + 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) + | 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 () => { diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 3f056aa4c..657b026c5 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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() ), }, { diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts index f2a7f5e31..f144e57d4 100644 --- a/apps/web/src/app/global-collection-route.component.ts +++ b/apps/web/src/app/global-collection-route.component.ts @@ -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(null); + readonly closeRequested = output(); + + 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 { + 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 | 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 { > @if (item.sourceType === 'xtream') { - + } @else if (item.sourceType === 'stalker') { - @@ -96,7 +199,9 @@ export class GlobalCollectionRouteComponent { readonly mode = input('favorites'); readonly defaultScope = input(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(() => - this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all') + readonly effectiveDefaultScope = computed( + () => + this.activePlaylistId() + ? 'playlist' + : (this.defaultScope() ?? 'all') ); addPlaylist(): void { diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index d2af6bea4..5052a925c 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -42,6 +42,7 @@ interface ErrorStatus { }) export class ElectronService extends DataService { private eventListeners: { [key: string]: () => void } = {}; + private messageListeners = new Map(); 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 { diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 3f7a26c4a..e82872587 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -52,6 +52,7 @@ interface ErrorStatus { providedIn: 'root', }) export class PwaService extends DataService { + private messageListeners = new Map(); 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 { diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 2e2395d20..bf379c6ea 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -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, + }, ]; } diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html new file mode 100644 index 000000000..b0887b15f --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -0,0 +1,179 @@ +
+
+
+ info +
+
+

{{ 'SETTINGS.ABOUT' | translate }}

+

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.VERSION' | translate }}

+

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

+
+
+ {{ version() }} + {{ updateMessage() }} +
+
+ + @if (isDesktop()) { +
+ {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate + }} + {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate + }} +
+ } + + + +
+
+
+
+ favorite +
+
+

{{ 'SETTINGS.SUPPORT_TITLE' | translate }}

+

{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}

+
+
+ +
+
+
diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts new file mode 100644 index 000000000..0c2be99a9 --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -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(); + readonly isDesktop = input(false); + readonly version = input(); + readonly updateMessage = input(); +} diff --git a/apps/web/src/app/settings/settings-backup-section.component.html b/apps/web/src/app/settings/settings-backup-section.component.html new file mode 100644 index 000000000..0a9f7189d --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.html @@ -0,0 +1,77 @@ +
+
+
+ backup +
+
+

{{ 'SETTINGS.BACKUP' | translate }}

+

{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

+

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

+
+
+ + +
+
+ + @if (isPwa()) { +
+ {{ 'SETTINGS.EPG_NOTE' | translate }} +  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} +
+ } +
diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts new file mode 100644 index 000000000..9cfb5af6c --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -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(); + readonly isPwa = input(false); + readonly isRemovingAllPlaylists = input(false); + readonly isExportingData = input(false); + + readonly importData = output(); + readonly exportData = output(); +} diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html new file mode 100644 index 000000000..6e7f01938 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -0,0 +1,127 @@ +
+
+
+ list_alt +
+
+

{{ 'SETTINGS.EPG_SOURCES' | translate }}

+

{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}

+
+
+ +
+
+ + @if (epgUrl().length === 0) { +
+ event_busy +

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

+

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

+
+ } @else { +
+ @for (_ of epgUrl().controls; track _; let i = $index) { +
+ + {{ + 'SETTINGS.EPG_URL_PLACEHOLDER' + | translate + }} + + {{ + 'SETTINGS.EPG_URL_ERROR' | translate + }} + + @if (epgField.value) { + + } + + +
+ } +
+ } +
+
+
+ +
+
+ + + +
+
+
diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts new file mode 100644 index 000000000..6d03e14ff --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly epgUrl = input.required(); + readonly isClearingEpgData = input(false); + + readonly refreshEpg = output(); + readonly removeEpgSource = output(); + readonly addEpgSource = output(); + readonly refreshAllEpg = output(); + readonly clearEpgData = output(); +} diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts new file mode 100644 index 000000000..f7c91d176 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -0,0 +1,7 @@ +import { FormControl, Validators } from '@angular/forms'; + +export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/; + +export function createEpgUrlControl(value = ''): FormControl { + return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]); +} diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html new file mode 100644 index 000000000..4e831237e --- /dev/null +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -0,0 +1,174 @@ +
+
+
+ settings +
+
+

{{ 'SETTINGS.GENERAL' | translate }}

+

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.LANGUAGE' | translate }}

+

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for ( + language of languageEnum() | keyvalue; + track language + ) { + {{ + 'LANGUAGES.' + language.key | translate + }} + } + + +
+
+ +
+
+

{{ 'SETTINGS.THEME' | translate }}

+

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

+
+
+
+ @for (themeOption of themeOptions(); track themeOption.value) { + + } +
+
+
+ +
+
+

{{ 'SETTINGS.COVER_SIZE' | translate }}

+

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

+
+
+
+ @for (option of coverSizeOptions(); track option.value) { + + } +
+
+
+ +
+
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

+

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

+
+
+ +
+
+ +
+
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

+

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

+
+
+ + {{ + 'SETTINGS.STARTUP_BEHAVIOR' | translate + }} + + @for ( + option of startupBehaviorOptions(); + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+ +
+
+

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

+

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

+
+
+ +
+
+
diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts new file mode 100644 index 000000000..be634dc9d --- /dev/null +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly languageEnum = input.required(); + readonly themeOptions = input.required(); + readonly coverSizeOptions = input.required(); + readonly startupBehaviorOptions = input.required(); + + readonly selectTheme = output(); + readonly selectCoverSize = output(); +} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts new file mode 100644 index 000000000..0e05271c5 --- /dev/null +++ b/apps/web/src/app/settings/settings-options.ts @@ -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, + }, + ]; +} diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html new file mode 100644 index 000000000..b036e6615 --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -0,0 +1,150 @@ +
+
+
+ play_circle +
+
+

{{ 'SETTINGS.PLAYBACK' | translate }}

+

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

+

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for (player of players(); track player.id) { + {{ player.labelKey | translate }} + + } + + +
+
+ +
+
+

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

+

{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate + }} + + @for ( + streamFormat of streamFormatEnum() | keyvalue; + track streamFormat + ) { + {{ streamFormat.value }} + + } + + +
+
+ + @if (isDesktop()) { +
+
+

{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}

+

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

+
+
+ +
+
+ } + + @if (form().value.player === 'mpv') { +
+
+

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

+

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

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

{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}

+

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

+
+
+ +
+
+ } + + @if (form().value.player === 'vlc') { +
+
+

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

+

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

+
+
+ + {{ + 'SETTINGS.VLC_PLAYER_PATH' | translate + }} + + +
+
+ } +
diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts new file mode 100644 index 000000000..c5b1519ad --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly players = input.required(); + readonly streamFormatEnum = input.required(); + readonly isDesktop = input(false); +} diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html new file mode 100644 index 000000000..c49c44954 --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control-section.component.html @@ -0,0 +1,139 @@ +
+
+
+ settings_remote +
+
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+
+
+ +
+
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+
+
+ +
+
+ + @if (form().value.remoteControl === true) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}

+

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

+
+
+ + {{ + 'SETTINGS.PORT_RANGE_LABEL' | translate + }} + + @if ( + form().get('remoteControlPort')?.hasError('required') + ) { + {{ + 'SETTINGS.PORT_REQUIRED' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('min')) { + {{ + 'SETTINGS.PORT_MIN' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('max')) { + {{ + 'SETTINGS.PORT_MAX' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('pattern')) { + {{ + 'SETTINGS.PORT_PATTERN' | translate + }} + } + +
+
+ + @if (localIpAddresses().length > 0) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}

+

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

+
+
+ @for (ip of localIpAddresses(); track ip) { +
+ + @if (visibleQrCodeIp() === ip) { +
+ +
+ } +
+ } +
+
+ } + } +
diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts new file mode 100644 index 000000000..5d243e36a --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -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(); + readonly activeSection = input.required(); + readonly localIpAddresses = input.required(); + readonly visibleQrCodeIp = input(null); + + readonly toggleQrCode = output(); +} diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html new file mode 100644 index 000000000..608dee007 --- /dev/null +++ b/apps/web/src/app/settings/settings-reset-section.component.html @@ -0,0 +1,88 @@ +
+
+
+ delete_sweep +
+
+

{{ 'SETTINGS.RESET' | translate }}

+

{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}

+
+
+ +
+
+
+

{{ 'SETTINGS.REMOVE_ALL' | translate }}

+

{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}

+
+ +
+
+ + folder_open + {{ + 'HOME.PLAYLIST_TYPES.M3U' | translate + }} + {{ + playlistDeleteSummary().m3u + }} + + + cloud + {{ + 'HOME.PLAYLIST_TYPES.XTREAM' | translate + }} + {{ + playlistDeleteSummary().xtream + }} + + + cast + {{ + 'HOME.PLAYLIST_TYPES.STALKER' | translate + }} + {{ + playlistDeleteSummary().stalker + }} + +
+ @if (removeAllProgressLabel(); as progressLabel) { +

+ {{ progressLabel }} +

+ } +
+
diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts new file mode 100644 index 000000000..7e42e0f40 --- /dev/null +++ b/apps/web/src/app/settings/settings-reset-section.component.ts @@ -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(); + readonly isRemovingAllPlaylists = input(false); + readonly canRemoveAllPlaylists = input(false); + readonly playlistDeleteSummary = + input.required(); + readonly removeAllProgressLabel = input(null); + + readonly removeAll = output(); +} diff --git a/apps/web/src/app/settings/settings-section-scroll.directive.ts b/apps/web/src/app/settings/settings-section-scroll.directive.ts new file mode 100644 index 000000000..1550d6d41 --- /dev/null +++ b/apps/web/src/app/settings/settings-section-scroll.directive.ts @@ -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); + 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; + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 76f79c56e..a18a88f17 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -6,6 +6,7 @@ class="settings-container" data-test-id="settings-container" [class.dialog-mode]="isDialog" + appSettingsSectionScroll >
-
-
-
- settings -
-
-

{{ 'SETTINGS.GENERAL' | translate }}

-

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-
-
+ -
-
-

{{ 'SETTINGS.LANGUAGE' | translate }}

-

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - language of languageEnum | keyvalue; - track language - ) { - {{ - 'LANGUAGES.' + language.key - | translate - }} - } - - -
-
- -
-
-

{{ 'SETTINGS.THEME' | translate }}

-

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

-
-
-
- @for ( - themeOption of themeOptions; - track themeOption.value - ) { - - } -
-
-
- -
-
-

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

-

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

-
-
- -
-
- -
-
-

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

-

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

-
-
- - {{ - 'SETTINGS.STARTUP_BEHAVIOR' | translate - }} - - @for ( - option of startupBehaviorOptions; - track option.value - ) { - - {{ option.labelKey | translate }} - - } - - -
-
- -
-
-

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

-

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

-
-
- -
-
-
- -
-
-
- play_circle -
-
-

{{ 'SETTINGS.PLAYBACK' | translate }}

-

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

-
-
- -
-
-

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

-

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

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for (player of players(); track player.id) { - {{ player.labelKey | translate }} - - } - - -
-
- -
-
-

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

-

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

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - streamFormat of streamFormatEnum | keyvalue; - track streamFormat - ) { - {{ streamFormat.value }} - - } - - -
-
- - @if (isDesktop) { -
-
-

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

-

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

-
-
- -
-
- } - - @if (settingsForm.value.player === 'mpv') { -
-
-

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

-

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

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

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

-

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

-
-
- -
-
- } - - @if (settingsForm.value.player === 'vlc') { -
-
-

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

-

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

-
-
- - {{ - 'SETTINGS.VLC_PLAYER_PATH' | translate - }} - - -
-
- } -
+ @if (isDesktop) { -
-
-
- list_alt -
-
-

{{ 'SETTINGS.EPG_SOURCES' | translate }}

-

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

-
-
+ -
-
- - @if (epgUrl.length === 0) { -
- event_busy -

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

-

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

-
- } @else { -
- @for ( - _ of epgUrl.controls; - track _; - let i = $index - ) { -
- - {{ - 'SETTINGS.EPG_URL_PLACEHOLDER' - | translate - }} - - {{ - 'SETTINGS.EPG_URL_ERROR' - | translate - }} - - @if (epgField.value) { - - } - - -
- } -
- } -
-
-
- -
-
- - - -
-
-
+ } - @if (isDesktop) { -
-
-
- settings_remote -
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

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

-
-
+ -
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

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

-
-
- -
-
+ - @if (settingsForm.value.remoteControl === true) { -
-
-

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

-

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

-
-
- - {{ - 'SETTINGS.PORT_RANGE_LABEL' | translate - }} - - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('required') - ) { - {{ - 'SETTINGS.PORT_REQUIRED' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('min') - ) { - {{ - 'SETTINGS.PORT_MIN' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('max') - ) { - {{ - 'SETTINGS.PORT_MAX' | translate - }} - } - @if ( - settingsForm - .get('remoteControlPort') - ?.hasError('pattern') - ) { - {{ - 'SETTINGS.PORT_PATTERN' | translate - }} - } - -
-
- - @if (localIpAddresses().length > 0) { -
-
-

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

-

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

-
-
- @for (ip of localIpAddresses(); track ip) { -
- - @if (visibleQrCodeIp() === ip) { -
- -
- } -
- } -
-
- } - } -
- } - -
-
-
- backup -
-
-

{{ 'SETTINGS.BACKUP' | translate }}

-

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

-
-
- -
-
-

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

-

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

-
-
- - -
-
- - @if (isPwa) { -
- {{ 'SETTINGS.EPG_NOTE' | translate }} -  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} -
- } -
- -
-
-
- delete_sweep -
-
-

{{ 'SETTINGS.RESET' | translate }}

-

{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}

-
-
- -
-
-
-

{{ 'SETTINGS.REMOVE_ALL' | translate }}

-

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

-
- -
-
- - folder_open - {{ - 'HOME.PLAYLIST_TYPES.M3U' | translate - }} - {{ - playlistDeleteSummary().m3u - }} - - - cloud - {{ - 'HOME.PLAYLIST_TYPES.XTREAM' | translate - }} - {{ - playlistDeleteSummary().xtream - }} - - - cast - {{ - 'HOME.PLAYLIST_TYPES.STALKER' | translate - }} - {{ - playlistDeleteSummary().stalker - }} - -
- @if (removeAllProgressLabel(); as progressLabel) { -

- {{ progressLabel }} -

- } -
-
- -
-
-
- info -
-
-

{{ 'SETTINGS.ABOUT' | translate }}

-

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

-
-
- -
-
-

{{ 'SETTINGS.VERSION' | translate }}

-

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

-
-
- {{ version }} - {{ updateMessage }} -
-
- - @if (isDesktop) { -
- {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate - }} - {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' - | translate - }} -
- } - - - -
-
-
-
- favorite -
-
-

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

-

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

-
-
- -
-
-
+