diff --git a/.changes/web-initial-path-audit.md b/.changes/web-initial-path-audit.md new file mode 100644 index 000000000..f1017a28c --- /dev/null +++ b/.changes/web-initial-path-audit.md @@ -0,0 +1,8 @@ +--- +type: perf +area: web +--- + +The app now loads about 40% less code before it shows its first screen: +channel lists, program guide views, Stalker portal tools and a few dialogs +are loaded when they are first needed instead of at startup. diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md deleted file mode 100644 index 6fdb44076..000000000 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: "\U0001F41E Bug report" -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - -- PWA or Electron/Tauri application -- OS: [e.g. Linux] -- Browser [e.g. chrome, safari] -- Version [e.g. 0.16.0] -- Feature: [e.g. M3U playlist, Xtream Code, Stalker Portal] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/---feature-request.md b/.github/ISSUE_TEMPLATE/---feature-request.md deleted file mode 100644 index 24405ec4f..000000000 --- a/.github/ISSUE_TEMPLATE/---feature-request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: "\U0001F680 Feature request" -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..94e7ddad3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,126 @@ +name: 🐞 Bug report +description: Something in IPTVnator doesn't work as expected. +title: '[Bug]: ' +labels: ['bug :bug:'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! The details below help us reproduce it on the first try. + + - **A channel, movie or episode doesn't play or stops?** The **Playback problem** form asks the right questions for that. + - **A question or an idea?** Please use [Discussions](https://github.com/4gray/iptvnator/discussions) or the **Feature request** form. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I'm on the latest release (or a nightly build) and the problem still happens there. + required: true + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from everything I paste below. + required: true + - type: input + id: version + attributes: + label: IPTVnator version + description: Shown in **Settings → About**. For a nightly build, add the commit shown there too. + placeholder: 0.24.0 + validations: + required: true + - type: dropdown + id: install + attributes: + label: How do you run IPTVnator? + options: + - Windows installer (.exe) + - macOS app (.dmg or .zip) + - macOS via Homebrew + - Linux AppImage + - Linux .deb, .rpm or pacman package + - Linux Snap + - Linux Flatpak + - Arch Linux AUR (iptvnator-bin) + - Self-hosted web app (Docker) + - Built from source + validations: + required: true + - type: input + id: os + attributes: + label: Operating system and version + description: For the self-hosted web app, add the browser you use as well. + placeholder: Windows 11 24H2 · macOS 26.6 on Apple Silicon · Ubuntu 24.04 · Docker on Unraid + Firefox + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source type + description: Which kind of source is affected? Pick all that apply. + multiple: true + options: + - M3U playlist added by URL + - M3U playlist added from a file or text + - Xtream Codes + - Stalker portal + - Not related to a source + validations: + required: true + - type: dropdown + id: player + attributes: + label: Video player + description: The player selected in **Settings → Playback → Video player**. + options: + - HTML5 video player + - Video.js player + - ArtPlayer + - Embedded MPV (Experimental) + - Embedded MPV with the frame-copy engine + - MPV player (external) + - VLC (external) + - Not related to playback + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Did this work in an earlier version? + options: + - Yes, it worked in an earlier version + - No, it never worked for me + - I don't know + validations: + required: true + - type: input + id: last-working-version + attributes: + label: Last version where it worked + description: Only if it worked before. + placeholder: 0.23.0 + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you see, and what did you expect to happen instead? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open an Xtream Codes playlist + 2. Go to Live TV and pick a category + 3. … + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics, logs and screenshots + description: | + If an error screen appears during playback, click **Copy diagnostics** and paste the report here; it contains no stream URLs or credentials. + You can also drag screenshots or a short screen recording into this field. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..983d32582 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Questions and help + url: https://github.com/4gray/iptvnator/discussions + about: Ask how to set something up, or share ideas with other users. + - name: 🐳 Self-hosting with Docker + url: https://github.com/4gray/iptvnator/blob/master/docker/README.md + about: Setup, environment variables and reverse-proxy notes for the self-hosted web app. + - name: 🌐 Website and downloads + url: https://4gray.github.io/iptvnator/ + about: Download links for every platform, FAQ and release announcements. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..087cad573 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,72 @@ +name: 🚀 Feature request +description: Suggest a new feature or an improvement. +title: '[Feature]: ' +labels: ['enhancement :magic_wand:'] +body: + - type: markdown + attributes: + value: | + Thanks for the idea! If a similar request already exists, a 👍 and your use case there help more than a new issue. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been requested yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from any examples or screenshots I share. + required: true + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Describe what you're trying to do and what gets in the way today. + validations: + required: true + - type: textarea + id: solution + attributes: + label: What would you like to happen? + description: How should it work from your point of view? Mockups and examples from other apps are welcome. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + multiple: true + options: + - Live TV and programme guide (EPG) + - Movies and series + - Playback and player controls + - Playlists and sources + - Favorites, history and downloads + - Search + - Settings, backup and sync + - Installation, updates and packaging + - Self-hosted web app (Docker) + - Other + - type: dropdown + id: source + attributes: + label: Source types + description: Which sources should this work with? + multiple: true + options: + - M3U playlists + - Xtream Codes + - Stalker portals + - All sources + - type: textarea + id: alternatives + attributes: + label: Alternatives you've considered + description: Workarounds you use today, or other ways to solve the problem. + - type: dropdown + id: contribute + attributes: + label: Would you like to help? + options: + - Yes, I'd like to open a pull request + - I can help test it + - Not right now diff --git a/.github/ISSUE_TEMPLATE/playback_problem.yml b/.github/ISSUE_TEMPLATE/playback_problem.yml new file mode 100644 index 000000000..dec23851c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/playback_problem.yml @@ -0,0 +1,150 @@ +name: ▶️ Playback problem +description: A channel, movie or episode doesn't start, stops, stutters, or has no picture or sound. +title: '[Playback]: ' +labels: ['bug :bug:'] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a playback problem! + + **Quick check first:** the built-in HTML5, Video.js and ArtPlayer players can't decode some codecs, for example HEVC/4K, AC3 or MPEG-2 video. + - **Desktop app:** such streams usually play with **Embedded MPV** or an external **MPV** or **VLC** (**Settings → Playback → Video player**). Whether the stream plays there tells us a lot, so please mention it below. + - **Self-hosted web app:** only the browser players are available. HLS, DASH and MPEG-TS streams may fail when the provider doesn't send CORS headers, and the browser may block `http://` streams on an `https://` page. Trying the same stream in the desktop app or in VLC helps us narrow it down. + - type: checkboxes + id: checks + attributes: + label: Before you submit + options: + - label: I'm on the latest release (or a nightly build) and the problem still happens there. + required: true + - label: I searched the [existing issues](https://github.com/4gray/iptvnator/issues?q=is%3Aissue) and this hasn't been reported yet. + required: true + - label: I removed usernames, passwords, MAC addresses, tokens and private URLs from everything I paste below. + required: true + - type: input + id: version + attributes: + label: IPTVnator version + description: Shown in **Settings → About**. For a nightly build, add the commit shown there too. + placeholder: 0.24.0 + validations: + required: true + - type: dropdown + id: install + attributes: + label: How do you run IPTVnator? + options: + - Windows installer (.exe) + - macOS app (.dmg or .zip) + - macOS via Homebrew + - Linux AppImage + - Linux .deb, .rpm or pacman package + - Linux Snap + - Linux Flatpak + - Arch Linux AUR (iptvnator-bin) + - Self-hosted web app (Docker) + - Built from source + validations: + required: true + - type: input + id: os + attributes: + label: Operating system and version + description: For the self-hosted web app, add the browser you use as well. + placeholder: Windows 11 24H2 · macOS 26.6 on Apple Silicon · Ubuntu 24.04 · Docker on Unraid + Firefox + validations: + required: true + - type: dropdown + id: source + attributes: + label: Source type + options: + - M3U playlist + - Xtream Codes + - Stalker portal + validations: + required: true + - type: dropdown + id: content + attributes: + label: What were you playing? + options: + - Live TV channel + - Catch-up / archive programme + - Movie + - Series episode + - Radio station + validations: + required: true + - type: dropdown + id: player + attributes: + label: Video player + description: The player selected in **Settings → Playback → Video player**. + options: + - HTML5 video player + - Video.js player + - ArtPlayer + - Embedded MPV (Experimental) + - Embedded MPV with the frame-copy engine + - MPV player (external) + - VLC (external) + - Audio player (radio station) + validations: + required: true + - type: dropdown + id: symptoms + attributes: + label: What goes wrong? + multiple: true + options: + - It doesn't start or keeps loading + - An error message appears + - It stops after a while + - It stutters or keeps buffering + - No picture, only sound + - No sound, or the wrong audio track + - Subtitles are missing or wrong + - Seeking or resuming goes to the wrong position + - Something else + validations: + required: true + - type: dropdown + id: elsewhere + attributes: + label: Does the same stream play elsewhere? + options: + - Yes, with another player in IPTVnator + - Yes, in VLC or another app outside IPTVnator + - No, it fails everywhere I tried + - I haven't tried + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Did this work in an earlier version? + description: If it did, please name the last version that worked under **What happened?**. + options: + - Yes, it worked in an earlier version + - No, it never worked for me + - I don't know + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: | + Describe what you see and when. If it stops, roughly after how long? If it worked before, which version was the last one that worked? + Mention the stream format if you know it (HLS .m3u8, MPEG-TS .ts, DASH .mpd, MKV…). Please don't paste full stream URLs that contain credentials or tokens. + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics, logs and screenshots + description: | + If an error screen appears, click **Copy diagnostics** and paste the report here; it contains no stream URLs or credentials. + If playback starts, the values from **Stream info** in the player overlay help too. Screenshots and short recordings are welcome. diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 24d672d5e..d3fb6874b 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -87,8 +87,30 @@ jobs: if: matrix.shard == 1 run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts - - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps + # The suite drives Electron through Playwright's _electron API and + # never launches a Playwright browser (or records video, which + # would need Playwright's ffmpeg), so no `playwright install`. + # The Ubuntu runner image already ships Electron's shared + # libraries and xvfb; fail fast with a clear message if an image + # update ever drops one. Electron 42+ downloads its binary on the + # first `require('electron')` (previously inside + # `_electron.launch()`), so resolve it the same way first. + - name: Check Electron runtime dependencies + if: runner.os == 'Linux' + run: | + command -v xvfb-run || { echo "::error::xvfb-run is missing on the runner"; exit 1; } + node -e "require('electron')" || { echo "::error::Electron binary download failed"; exit 1; } + electron_bin="$(node -p "require('electron')")" + [ -x "$electron_bin" ] || { echo "::error::Electron binary not found at $electron_bin"; exit 1; } + ldd_status=0 + libs="$(ldd "$electron_bin")" || ldd_status=$? + missing="$(grep 'not found' <<< "$libs" || true)" + if [ -n "$missing" ]; then + echo "::error::Electron is missing shared libraries:" + echo "$missing" + exit 1 + fi + [ "$ldd_status" -eq 0 ] || { echo "::error::ldd failed on $electron_bin (exit $ldd_status)"; exit 1; } - name: Run Electron E2E Tests (Linux) if: runner.os == 'Linux' diff --git a/apps/web/project.json b/apps/web/project.json index 302ea2e37..72b30b14e 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -57,8 +57,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "4.5mb", - "maximumError": "5mb" + "maximumWarning": "1.8mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", @@ -88,8 +88,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "4.5mb", - "maximumError": "5mb" + "maximumWarning": "1.8mb", + "maximumError": "2mb" }, { "type": "anyComponentStyle", diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts index f567a2573..d92cc47d9 100644 --- a/apps/web/src/app/app-update-notification-panel.component.spec.ts +++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts @@ -73,7 +73,7 @@ describe('AppUpdateNotificationPanelComponent', () => { ).not.toBeNull(); }); - it('opens release notes without dismissing the notification', () => { + it('opens release notes without dismissing the notification', async () => { statusHandler?.(availableStatus); fixture.detectChanges(); @@ -82,6 +82,9 @@ describe('AppUpdateNotificationPanelComponent', () => { '[data-test-id="app-update-notification-release-notes"]' ) as HTMLButtonElement ).click(); + // The dialog component is imported on demand (it pulls in `marked`). + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); expect(TestBed.inject(MatDialog).open).toHaveBeenCalledWith( AppUpdateReleaseNotesDialogComponent, @@ -96,6 +99,29 @@ describe('AppUpdateNotificationPanelComponent', () => { ).not.toBeNull(); }); + it('falls back to the releases page when the release notes dialog fails to load', async () => { + const open = jest.spyOn(window, 'open').mockReturnValue(null); + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + fixture.componentInstance.loadReleaseNotesDialog = () => + Promise.reject(new Error('chunk failed')); + statusHandler?.(availableStatus); + fixture.detectChanges(); + + await fixture.componentInstance.openReleaseNotes(); + + expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith( + availableStatus.manualDownloadUrl, + '_blank', + 'noreferrer' + ); + expect(error).toHaveBeenCalledTimes(1); + open.mockRestore(); + error.mockRestore(); + }); + it('starts downloading the update from the notification action', async () => { statusHandler?.(availableStatus); fixture.detectChanges(); diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts index d10b02117..a99e814ad 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -16,7 +16,6 @@ import { ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; import { AppUpdateInstallService } from './services/app-update-install.service'; -import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component'; @Component({ selector: 'app-update-notification-panel', @@ -239,8 +238,31 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy { this.unsubscribeStatus = null; } - openReleaseNotes(): void { - const latestVersion = this.status()?.latestVersion; + /** + * Loaded on demand: the dialog renders Markdown with `marked`, which this + * always-mounted panel would otherwise put on the initial path. A field so + * specs can substitute it. + */ + loadReleaseNotesDialog = () => + import('./settings/app-update-release-notes-dialog.component'); + + async openReleaseNotes(): Promise { + const status = this.status(); + const latestVersion = status?.latestVersion; + let dialogModule: Awaited< + ReturnType + >; + try { + dialogModule = await this.loadReleaseNotesDialog(); + } catch (error) { + // The same notes are on the releases page; the next click retries. + console.error('Could not load the release notes dialog:', error); + if (status?.manualDownloadUrl) { + window.open(status.manualDownloadUrl, '_blank', 'noreferrer'); + } + return; + } + const { AppUpdateReleaseNotesDialogComponent } = dialogModule; this.dialog.open(AppUpdateReleaseNotesDialogComponent, { autoFocus: false, diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 81c36cc9c..8c61fb626 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -21,7 +21,9 @@ import { WORKSPACE_SHELL_ACTIONS, } from '@iptvnator/workspace/shell/util'; import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel'; -import { WindowControlsComponent } from '@iptvnator/ui/components'; +// File-level entry: the @iptvnator/ui/components barrel would put the whole +// library (channel lists, EPG, forms, date-fns) on the initial path. +import { WindowControlsComponent } from '@iptvnator/ui/components/window-controls'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { filter, take } from 'rxjs'; import { diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index b1cb80c9b..70001db7b 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -31,7 +31,7 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; -import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; import { provideParentalLockPlaylistCleanup, @@ -47,7 +47,7 @@ import { provideParentalLockPrompt } from './services/parental-lock-prompt.servi import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { PwaService } from './services/pwa.service'; import { shouldEnableServiceWorker } from './services/runtime-config'; -import { AppStalkerPlaylistConnectionEditorService } from './services/stalker-playlist-connection-editor.service'; +import { LazyStalkerPlaylistConnectionEditor } from './services/lazy-stalker-playlist-connection-editor'; import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service'; // AoT requires an exported function for factories @@ -161,7 +161,7 @@ export const appConfig: ApplicationConfig = { ...providePortalPlaybackPositions(), { provide: STALKER_PLAYLIST_CONNECTION_EDITOR, - useExisting: AppStalkerPlaylistConnectionEditorService, + useExisting: LazyStalkerPlaylistConnectionEditor, }, ...provideWorkspaceShellActions(), ...provideParentalLockPrompt(), diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 525ff5645..dd5be4f0b 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -3,7 +3,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DialogService } from '@iptvnator/ui/components'; +import { DialogService } from '@iptvnator/ui/components/confirm-dialog'; import { DataService, SettingsStore, diff --git a/apps/web/src/app/services/external-player-info-dialog.lazy.ts b/apps/web/src/app/services/external-player-info-dialog.lazy.ts new file mode 100644 index 000000000..b5158073e --- /dev/null +++ b/apps/web/src/app/services/external-player-info-dialog.lazy.ts @@ -0,0 +1,9 @@ +/** + * Lazy boundary for PlayerService: the service imports this file dynamically, + * so the dialog and the @angular/forms it brings (via the Material checkbox) + * stay off the initial path. The boundary is a local file rather than the + * library path because other web files, such as specs, import ui-playback + * statically, which @nx/enforce-module-boundaries forbids for a library the + * project also loads dynamically. + */ +export { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog'; diff --git a/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts new file mode 100644 index 000000000..4a35313b4 --- /dev/null +++ b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.spec.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { + StalkerPlaylistConnectionEditor, + StalkerPlaylistConnectionResult, +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; +import type { + PlaylistMeta, + PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; +import { LazyStalkerPlaylistConnectionEditor } from './lazy-stalker-playlist-connection-editor'; + +const resolved: StalkerPlaylistConnectionResult = { + status: 'resolved', + playlist: { _id: 'p1' } as PlaylistMetaUpdate, +}; + +@Injectable({ providedIn: 'root' }) +class FakeEditor implements StalkerPlaylistConnectionEditor { + resolveConnection = jest.fn(async () => resolved); + applyResolvedConnection = jest.fn( + async (playlist: PlaylistMetaUpdate) => playlist + ); +} + +type EditorModule = Awaited< + ReturnType +>; + +function moduleWith(editorClass: typeof FakeEditor): EditorModule { + return { + AppStalkerPlaylistConnectionEditorService: editorClass, + } as unknown as EditorModule; +} + +describe('LazyStalkerPlaylistConnectionEditor', () => { + let lazy: LazyStalkerPlaylistConnectionEditor; + + beforeEach(() => { + TestBed.configureTestingModule({}); + lazy = TestBed.inject(LazyStalkerPlaylistConnectionEditor); + }); + + it('loads nothing until an editor method is called', () => { + const load = jest.fn(async () => moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + + expect(load).not.toHaveBeenCalled(); + }); + + it('delegates both methods to the root-provided implementation, loading it once', async () => { + const load = jest.fn(async () => moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + const playlist = { _id: 'p1' } as PlaylistMeta; + const source = { _id: 'p0' } as PlaylistMeta; + const update = { _id: 'p1', title: 'Renamed' } as PlaylistMetaUpdate; + + await expect(lazy.resolveConnection(playlist, source)).resolves.toBe( + resolved + ); + await expect( + lazy.applyResolvedConnection(update, { + preserveCurrentMetadata: true, + }) + ).resolves.toBe(update); + + const editor = TestBed.inject(FakeEditor); + expect(editor.resolveConnection).toHaveBeenCalledWith(playlist, source); + expect(editor.applyResolvedConnection).toHaveBeenCalledWith(update, { + preserveCurrentMetadata: true, + }); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('retries the import after a failed chunk load', async () => { + const load = jest + .fn, []>() + .mockRejectedValueOnce(new Error('chunk failed')) + .mockResolvedValueOnce(moduleWith(FakeEditor)); + lazy.loadEditorModule = load; + const playlist = { _id: 'p1' } as PlaylistMeta; + + await expect(lazy.resolveConnection(playlist)).rejects.toThrow( + 'chunk failed' + ); + await expect(lazy.resolveConnection(playlist)).resolves.toBe(resolved); + expect(load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts new file mode 100644 index 000000000..187a59e43 --- /dev/null +++ b/apps/web/src/app/services/lazy-stalker-playlist-connection-editor.ts @@ -0,0 +1,56 @@ +import { EnvironmentInjector, inject, Injectable } from '@angular/core'; +import type { + StalkerPlaylistConnectionEditor, + StalkerPlaylistConnectionResult, + StalkerResolvedConnectionApplyOptions, +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; +import type { + PlaylistMeta, + PlaylistMetaUpdate, +} from '@iptvnator/shared/interfaces'; + +type EditorModule = + typeof import('./stalker-playlist-connection-editor.service'); + +/** + * Registered as STALKER_PLAYLIST_CONNECTION_EDITOR in app.config. The real + * editor depends on the whole Stalker portal data layer, which would + * otherwise be evaluated before the first paint although it is only needed + * when a user edits or re-checks a Stalker source. Every editor method is + * asynchronous, so the implementation is imported on first use and resolved + * from the root injector (it is providedIn: 'root'). + */ +@Injectable({ providedIn: 'root' }) +export class LazyStalkerPlaylistConnectionEditor implements StalkerPlaylistConnectionEditor { + private readonly injector = inject(EnvironmentInjector); + private editor: Promise | null = null; + + /** The dynamic import; a field so specs can substitute it. */ + loadEditorModule: () => Promise = () => + import('./stalker-playlist-connection-editor.service'); + + async resolveConnection( + playlist: PlaylistMeta, + sourcePlaylist?: PlaylistMeta + ): Promise { + const editor = await this.resolveEditor(); + return editor.resolveConnection(playlist, sourcePlaylist); + } + + async applyResolvedConnection( + playlist: PlaylistMetaUpdate, + options?: StalkerResolvedConnectionApplyOptions + ): Promise { + const editor = await this.resolveEditor(); + return editor.applyResolvedConnection(playlist, options); + } + + private resolveEditor(): Promise { + this.editor ??= this.loadEditorModule().then((module) => + this.injector.get(module.AppStalkerPlaylistConnectionEditorService) + ); + // A failed chunk load must not poison later attempts. + this.editor.catch(() => (this.editor = null)); + return this.editor; + } +} diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index 86ab12ca8..665fd25eb 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -1,7 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { DataService } from '@iptvnator/services'; -import { ExternalPlayerSession, VideoPlayer } from '@iptvnator/shared/interfaces'; +import { + ExternalPlayerSession, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; import { SettingsStore } from './settings-store.service'; import { PlayerService } from './player.service'; @@ -92,6 +95,57 @@ describe('PlayerService', () => { expect(result).toEqual(session); }); + it('opens the external-player info dialog once its chunk has loaded', async () => { + settingsStore.player.mockReturnValue(VideoPlayer.MPV); + dataService.sendIpcEvent.mockResolvedValue(undefined); + + await service.openResolvedPlayback( + { streamUrl: 'https://example.com/video.mp4', title: 'Example' }, + false + ); + await new Promise((resolve) => setTimeout(resolve)); + + expect(dialog.open).toHaveBeenCalledTimes(1); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + 'OPEN_MPV_PLAYER', + expect.anything() + ); + }); + + it('still starts playback and logs when the info dialog fails to load', async () => { + const error = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + try { + settingsStore.player.mockReturnValue(VideoPlayer.VLC); + dataService.sendIpcEvent.mockResolvedValue(undefined); + service.loadExternalPlayerInfoDialog = () => + Promise.reject(new Error('chunk failed')); + + await service.openResolvedPlayback( + { + streamUrl: 'https://example.com/video.mp4', + title: 'Example', + }, + false + ); + await new Promise((resolve) => setTimeout(resolve)); + + expect(dialog.open).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + 'OPEN_VLC_PLAYER', + expect.anything() + ); + expect(error).toHaveBeenCalledTimes(1); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + error.mockRestore(); + } + }); + it('forces external playback without changing the selected embedded player', async () => { const session: ExternalPlayerSession = { id: 'session-2', diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index a37e32b66..fa686c46e 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -1,6 +1,5 @@ import { Injectable, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback/external-player-info-dialog'; import { DataService } from '@iptvnator/services'; import { ExternalPlayerSession, @@ -70,12 +69,12 @@ export class PlayerService { if (player === VideoPlayer.MPV) { if (!hideExternalInfoDialog) { - this.dialog.open(ExternalPlayerInfoDialogComponent); + this.showExternalPlayerInfoDialog(); } return await this.openExternalPlayback(playback, 'mpv'); } else if (player === VideoPlayer.VLC) { if (!hideExternalInfoDialog) { - this.dialog.open(ExternalPlayerInfoDialogComponent); + this.showExternalPlayerInfoDialog(); } return await this.openExternalPlayback(playback, 'vlc'); } @@ -83,12 +82,34 @@ export class PlayerService { return; } + /** + * Loaded on demand: the dialog's Material checkbox brings @angular/forms, + * and this service is created with the app, so a static import would put + * both on the initial path. Playback does not wait for the dialog. + */ + private showExternalPlayerInfoDialog(): void { + this.loadExternalPlayerInfoDialog() + .then(({ ExternalPlayerInfoDialogComponent }) => + this.dialog.open(ExternalPlayerInfoDialogComponent) + ) + .catch((error: unknown) => { + // Informational only: playback is already starting. + console.error( + 'Could not load the external player info dialog:', + error + ); + }); + } + + /** The dynamic import; a field so specs can substitute it. */ + loadExternalPlayerInfoDialog = () => + import('./external-player-info-dialog.lazy'); + async openExternalPlayback( playback: ResolvedPortalPlayback, player: ExternalPlayerName ): Promise { - const ipcEvent = - player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER; + const ipcEvent = player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER; return await this.dataService.sendIpcEvent( ipcEvent, diff --git a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts index ed50c4526..0f74424cd 100644 --- a/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts +++ b/apps/web/src/app/services/stalker-playlist-connection-editor.service.ts @@ -15,7 +15,7 @@ import { type StalkerPlaylistConnectionEditor, type StalkerPlaylistConnectionResult, type StalkerResolvedConnectionApplyOptions, -} from '@iptvnator/playlist/shared/ui'; +} from '@iptvnator/playlist/shared/ui/stalker-connection-editor'; import { normalizeStalkerPortalIdentity, type Playlist, diff --git a/apps/website/README.md b/apps/website/README.md index 9ede7d72b..7754a7b35 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -108,6 +108,16 @@ skip shows up in your output. ## Guides +`/guides/` is the task-based entry point: Getting started, Live TV & EPG, +Player & playback, Your library and Updates. `src/lib/guides.ts` owns the +reading order; titles and descriptions come from the existing blog collection, +and article URLs stay at `/blog//`. Add an article's slug to the matching +group when it should appear here. Missing slugs fail the build. Drafts follow +the blog's visibility rule (development mode or `PUBLIC_INCLUDE_DRAFTS=true`), +and preview entries carry a draft label. Updates also links to the latest +published release post. The hub emits CollectionPage and BreadcrumbList data +and is linked from the site header, footer and blog index. + Evergreen how-to posts live in the blog collection next to release notes (`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`, `m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx`, @@ -156,7 +166,10 @@ Evergreen how-to posts live in the blog collection next to release notes `tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks each guide for the FAQPage schema, a link to the download hub and the presence -of every referenced screenshot in the build output. +of every referenced screenshot in the build output. It also checks the task hub's +grouping, draft visibility, article links, structured data and navigation at +phone, tablet and desktop widths. Run the hub checks against a build made with +`PUBLIC_INCLUDE_DRAFTS=true` using the same environment flag to verify previews. ## Blog Tags diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt index f9df7f5ba..634fd4062 100644 --- a/apps/website/public/llms.txt +++ b/apps/website/public/llms.txt @@ -6,6 +6,7 @@ - Website: https://4gray.github.io/iptvnator/ - Blog: https://4gray.github.io/iptvnator/blog/ +- Guides by task: https://4gray.github.io/iptvnator/guides/ - Latest Release: https://github.com/4gray/iptvnator/releases/latest - Downloads overview: https://4gray.github.io/iptvnator/download/ - Download for Windows: https://4gray.github.io/iptvnator/download/windows/ diff --git a/apps/website/src/components/Footer.astro b/apps/website/src/components/Footer.astro index e7edb422e..adc9926fc 100644 --- a/apps/website/src/components/Footer.astro +++ b/apps/website/src/components/Footer.astro @@ -27,6 +27,7 @@ const socialLinks = [ const footerLinks = [ { label: 'Features', href: '/iptvnator/features/' }, + { label: 'Guides', href: '/iptvnator/guides/' }, { label: 'Compare', href: '/iptvnator/compare/' }, { label: 'Download', href: '/iptvnator/download/' }, { label: 'Blog', href: '/iptvnator/blog/' }, diff --git a/apps/website/src/components/Header.astro b/apps/website/src/components/Header.astro index a53dbca87..a384d176f 100644 --- a/apps/website/src/components/Header.astro +++ b/apps/website/src/components/Header.astro @@ -1,6 +1,7 @@ --- const navLinks = [ { label: 'Features', href: '/iptvnator/features/' }, + { label: 'Guides', href: '/iptvnator/guides/' }, { label: 'Compare', href: '/iptvnator/compare/' }, { label: 'Screenshots', href: '/iptvnator/#screenshots' }, { label: 'Download', href: '/iptvnator/download/' }, @@ -28,12 +29,13 @@ const navLinks = [ -