From 0b191554691ef0e0a613fcfa15f65abbbe23168b Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 02:02:06 +0300 Subject: [PATCH 1/6] ci: cache embedded mpv runtime --- .github/workflows/build-and-make.yaml | 68 +++++++++++++++++++++++- docs/architecture/embedded-mpv-native.md | 2 + tools/embedded-mpv/README.md | 2 + 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 0e2d636d4..b48f6d6c9 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -88,10 +88,64 @@ jobs: - name: Build frontend run: pnpm nx build web --skip-nx-cache + - name: Resolve embedded MPV runtime cache key + # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + id: embedded-mpv-runtime-cache-key + shell: bash + run: | + set -euo pipefail + + deployment_target="${MACOSX_DEPLOYMENT_TARGET:-11.0}" + safe_deployment_target="$(printf '%s' "${deployment_target}" | tr -c 'A-Za-z0-9_.-' '-')" + xcode_version="$(xcodebuild -version | tr '\n' ' ' | sed 's/[[:space:]]*$//')" + xcode_hash="$(printf '%s' "${xcode_version}" | shasum -a 256 | awk '{print $1}')" + source_hash="$( + shasum -a 256 \ + tools/embedded-mpv/build-macos-runtime.mjs \ + tools/embedded-mpv/stage-macos-runtime.mjs | + shasum -a 256 | + awk '{print $1}' + )" + cache_key="embedded-mpv-runtime-v1-${RUNNER_OS}-${{ matrix.arch }}-macos${safe_deployment_target}-xcode${xcode_hash}-${source_hash}" + + echo "deployment-target=${deployment_target}" >> "${GITHUB_OUTPUT}" + echo "xcode-version=${xcode_version}" >> "${GITHUB_OUTPUT}" + echo "key=${cache_key}" >> "${GITHUB_OUTPUT}" + echo "Embedded MPV runtime cache key: ${cache_key}" + echo "Xcode version: ${xcode_version}" + + - name: Restore embedded MPV runtime cache + # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + id: embedded-mpv-runtime-cache + uses: actions/cache/restore@v4 + with: + path: | + vendor/embedded-mpv/darwin-${{ matrix.arch }}/include + vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib + vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json + key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} + + - name: Clear non-exact embedded MPV runtime cache restore + # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + + rm -rf \ + "vendor/embedded-mpv/darwin-${{ matrix.arch }}/include" \ + "vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib" \ + "vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json" + - name: Build embedded MPV runtime (macOS release) # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail @@ -322,6 +376,18 @@ jobs: IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && '1' || '0' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" + - name: Save embedded MPV runtime cache + # TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` + # after the macOS Embedded MPV artifacts are built and manually tested. + if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && github.repository == '4gray/iptvnator' && github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + vendor/embedded-mpv/darwin-${{ matrix.arch }}/include + vendor/embedded-mpv/darwin-${{ matrix.arch }}/lib + vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json + key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} + - name: Smoke test packaged Flatpak launcher if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' shell: bash diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 8c2905040..7002f5f62 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -192,6 +192,8 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix ``` +During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/darwin-/` runtime and skip the expensive source build. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. + The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires. The Electron backend build consumes the staged runtime, copies Mach-O runtime files into the native build output, and rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index f1dde6138..0098db7a1 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -57,6 +57,8 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix pnpm embedded-mpv:stage-runtime -- arm64 /tmp/embedded-mpv-prefix ``` +During temporary PR and `master` artifact testing, CI restores an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/darwin-/` runtime before falling back to the source build. The cache key includes the target architecture, macOS deployment target, Xcode version, and hashes of the runtime build/staging scripts. Cache entries are saved only from trusted repository refs and are treated strictly as a speed optimization; tagged release builds continue to rebuild from pinned sources unless a future signed and attested runtime artifact flow is introduced. + The builder currently pins: - FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled From f3c0cd3820f7227c928f8c20b150da2c114530c8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 02:16:32 +0300 Subject: [PATCH 2/6] ci: clarify embedded mpv cache inputs --- .github/workflows/build-and-make.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index b48f6d6c9..caa78d4ee 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -101,6 +101,9 @@ jobs: safe_deployment_target="$(printf '%s' "${deployment_target}" | tr -c 'A-Za-z0-9_.-' '-')" xcode_version="$(xcodebuild -version | tr '\n' ' ' | sed 's/[[:space:]]*$//')" xcode_hash="$(printf '%s' "${xcode_version}" | shasum -a 256 | awk '{print $1}')" + # Runtime source pins, source URLs, git tags, and build flags + # live in build-macos-runtime.mjs; staging layout and manifest + # normalization live in stage-macos-runtime.mjs. source_hash="$( shasum -a 256 \ tools/embedded-mpv/build-macos-runtime.mjs \ @@ -129,7 +132,7 @@ jobs: vendor/embedded-mpv/darwin-${{ matrix.arch }}/runtime-manifest.json key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} - - name: Clear non-exact embedded MPV runtime cache restore + - name: Clear stale embedded MPV runtime files # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. if: matrix.os == 'macos' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' From 4ab8915483db92b192ccdcffd3adab4a3052491d Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 02:58:12 +0300 Subject: [PATCH 3/6] chore(web): enable strict TypeScript mode --- apps/web/src/app/app.component.ts | 16 +- ...edded-mpv-player-recording-message.spec.ts | 8 +- apps/web/src/app/services/electron.service.ts | 14 +- apps/web/src/app/services/pwa.service.ts | 37 +++-- apps/web/src/app/services/settings.service.ts | 20 +-- .../app/settings/settings.component.spec.ts | 3 +- .../src/app/settings/settings.component.ts | 81 +++++++--- apps/web/src/typings.d.ts | 3 +- apps/web/tsconfig.json | 2 +- .../add-playlist-dialog.component.html | 2 +- .../stalker-portal-import.component.ts | 14 +- .../xtream-code-import.component.html | 2 +- .../xtream-code-import.component.ts | 4 +- .../playlist-switcher.component.ts | 18 ++- .../recent-playlists.component.ts | 2 +- .../feature/src/lib/downloads.component.ts | 25 ++- .../unified-favorites-data.service.ts | 2 +- .../collection/unified-recent-data.service.ts | 2 +- .../category-view.component.html | 2 +- .../favorites-layout.component.ts | 11 +- .../grid-list/grid-list.component.ts | 14 +- .../playlist-error-view.component.ts | 4 +- .../navigation/workspace-portal-navigation.ts | 19 ++- .../src/lib/stalker-session.service.ts | 16 +- .../stalker-catalog-detail.component.ts | 10 +- .../src/lib/stalker-catalog-facade.service.ts | 38 ++--- .../stalker-collection-detail.component.ts | 2 +- .../stalker-favorites-button.component.ts | 13 +- .../stalker-inline-detail.component.html | 4 +- .../stalker-live-stream-layout.component.ts | 52 +++++-- .../stalker-search.component.html | 6 +- .../stalker-search.component.ts | 47 +++--- .../stalker-series-view.component.html | 26 ++-- .../stalker-series-view.component.ts | 7 +- .../lib/stalker-vod-playback-controller.ts | 4 +- .../electron-xtream-data-source.ts | 2 +- .../data-sources/pwa-xtream-data-source.ts | 4 +- .../stores/features/with-player.feature.ts | 14 +- .../data-access/src/lib/with-recent-items.ts | 10 +- .../portal-channels-list.component.html | 2 +- .../recently-added.component.html | 6 +- .../search-results.component.ts | 21 ++- .../serial-details.component.html | 39 ++--- .../serial-details.component.ts | 28 +++- .../vod-details-route.component.html | 33 ++-- .../vod-details-route.component.ts | 143 +++++++++++------ .../lib/xtream-collection-detail.component.ts | 2 +- libs/services/src/lib/data.service.ts | 26 +++- .../all-channels-view.component.html | 2 +- .../season-container.component.html | 146 +++++++++++++----- .../embedded-mpv-session-controller.ts | 2 +- .../src/lib/embedded-mpv-player/index.ts | 3 + .../lib/vjs-player/vjs-player.component.ts | 26 ++-- ...space-context-category-view.component.html | 2 +- tsconfig.base.json | 3 + 55 files changed, 675 insertions(+), 369 deletions(-) create mode 100644 libs/ui/playback/src/lib/embedded-mpv-player/index.ts diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index efda3665f..2183b900c 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -49,14 +49,16 @@ export class AppComponent implements OnInit { private readonly DEFAULT_LANG = Language.ENGLISH; constructor() { + const electronProcess = this.dataService.remote?.process; if ( - ((this.dataService.isElectron && - this.dataService?.remote?.process.platform === 'linux') || - this.dataService?.remote?.process.platform === 'win32') && - this.dataService.remote.process.argv.length > 2 + this.dataService.isElectron && + electronProcess && + (electronProcess.platform === 'linux' || + electronProcess.platform === 'win32') && + electronProcess.argv.length > 2 ) { - const filePath = this.dataService.remote.process.argv.find( - (filepath) => + const filePath = electronProcess.argv.find( + (filepath: string) => filepath.endsWith('.m3u') || filepath.endsWith('.m3u8') ); if (filePath) { @@ -102,7 +104,7 @@ export class AppComponent implements OnInit { */ initSettings(): void { this.settingsService - .getValueFromLocalStorage(STORE_KEY.Settings) + .getValueFromLocalStorage(STORE_KEY.Settings) .subscribe((settings: Settings) => { if (settings && Object.keys(settings).length > 0) { // No need to send settings to Electron on init diff --git a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts index d8932fac6..3e72d8d58 100644 --- a/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts +++ b/apps/web/src/app/embedded-mpv-player-recording-message.spec.ts @@ -2,9 +2,11 @@ import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; -import { EmbeddedMpvOverlayVisibilityService } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-overlay-visibility.service'; -import { EmbeddedMpvPlayerComponent } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component'; -import { EmbeddedMpvSessionController } from '../../../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller'; +import { + EmbeddedMpvOverlayVisibilityService, + EmbeddedMpvPlayerComponent, + EmbeddedMpvSessionController, +} from '@iptvnator/ui/playback/embedded-mpv-player'; @Component({ imports: [EmbeddedMpvPlayerComponent], diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 0adccd9e4..2ec59d00c 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -117,7 +117,7 @@ export class ElectronService extends DataService { payload?: unknown ): Promise { if (type === PLAYLIST_PARSE_BY_URL) { - this.fetchM3uPlaylistFromUrl(payload); + this.fetchM3uPlaylistFromUrl(payload as Partial); return undefined as T; } @@ -156,7 +156,7 @@ export class ElectronService extends DataService { data.url, data.title ?? '', data.thumbnail ?? '', - data['user-agent'] ?? undefined, + data['user-agent'] ?? '', data.referer ?? undefined, data.origin ?? undefined, data.contentInfo, @@ -185,7 +185,7 @@ export class ElectronService extends DataService { data.url, data.title ?? '', data.thumbnail ?? '', - data['user-agent'] ?? undefined, + data['user-agent'] ?? '', data.referer ?? undefined, data.origin ?? undefined, data.contentInfo, @@ -219,7 +219,7 @@ export class ElectronService extends DataService { this.translateService.instant( 'HOME.PLAYLISTS.AUTO_REFRESH_UPDATE_SUCCESS' ), - null, + undefined, { duration: 2000 } ); return playlists as T; @@ -266,6 +266,10 @@ export class ElectronService extends DataService { } private async fetchM3uPlaylistFromUrl(payload: Partial) { + if (!payload.url) { + return; + } + const title = payload.title?.trim() || undefined; window.electron @@ -353,7 +357,7 @@ export class ElectronService extends DataService { this.translateService.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' ), - null, + undefined, { duration: 2000 } ); } catch (error: unknown) { diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 25e2af2d5..c7a853b51 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -5,7 +5,14 @@ import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs'; +import { + catchError, + firstValueFrom, + from, + Observable, + switchMap, + throwError, +} from 'rxjs'; import { DataService } from '@iptvnator/services'; import { ERROR, @@ -108,12 +115,12 @@ export class PwaService extends DataService { */ sendIpcEvent(type: string, payload?: unknown): T { if (type === PLAYLIST_PARSE_BY_URL) { - this.fetchFromUrl(payload); + this.fetchFromUrl(payload as Partial); return undefined as T; } if (type === PLAYLIST_UPDATE) { - this.refreshPlaylist(payload); + this.refreshPlaylist(payload as Partial); return undefined as T; } @@ -138,6 +145,12 @@ export class PwaService extends DataService { } refreshPlaylist(payload: Partial) { + if (!payload.url || !payload.id) { + return; + } + + const playlistId = payload.id; + this.getPlaylistFromUrl(payload.url) .pipe( catchError((error) => { @@ -155,7 +168,7 @@ export class PwaService extends DataService { this.store.dispatch( PlaylistActions.updatePlaylist({ playlist, - playlistId: payload.id, + playlistId, }) ); @@ -163,7 +176,7 @@ export class PwaService extends DataService { this.translateService.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' ), - null, + undefined, { duration: 2000 } ); }); @@ -193,13 +206,19 @@ export class PwaService extends DataService { * @param payload playlist payload */ fetchFromUrl(payload: Partial): void { + if (!payload.url) { + return; + } + const title = payload.title?.trim() || undefined; this.getPlaylistFromUrl(payload.url) .pipe( catchError((error) => { this.snackBar.open( - this.getErrorMessageByStatusCode(error.status), + this.getErrorMessageByStatusCode( + this.extractHttpStatusCode(error) + ), 'Close', { duration: 5000, @@ -226,7 +245,7 @@ export class PwaService extends DataService { }); } - getErrorMessageByStatusCode(status: number) { + getErrorMessageByStatusCode(status: number | null | undefined) { let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED'; switch (status) { case 413: @@ -524,10 +543,10 @@ export class PwaService extends DataService { } } - getPlaylistFromUrl(url: string) { + getPlaylistFromUrl(url: string): Observable { return from(this.getProviderTargetId(url)).pipe( switchMap((targetId) => - this.http.get(`${this.corsProxyUrl}/parse`, { + this.http.get(`${this.corsProxyUrl}/parse`, { params: { targetId }, }) ) diff --git a/apps/web/src/app/services/settings.service.ts b/apps/web/src/app/services/settings.service.ts index da980f203..a75978e3e 100644 --- a/apps/web/src/app/services/settings.service.ts +++ b/apps/web/src/app/services/settings.service.ts @@ -115,7 +115,10 @@ export class SettingsService { const mediaQuery = this.systemThemeMediaQuery as LegacyMediaQueryList; if (mediaQuery.addEventListener) { - mediaQuery.addEventListener('change', this.systemThemeChangeHandler); + mediaQuery.addEventListener( + 'change', + this.systemThemeChangeHandler + ); } else { mediaQuery.addListener?.(this.systemThemeChangeHandler); } @@ -147,8 +150,8 @@ export class SettingsService { * @param key key to get * @returns returns the value of the given key */ - getValueFromLocalStorage(key: STORE_KEY) { - return this.storage.get(key); + getValueFromLocalStorage(key: STORE_KEY): Observable { + return this.storage.get(key) as Observable; } /** @@ -175,9 +178,9 @@ export class SettingsService { */ getAppVersion() { return this.http - .get<{ created_at: string; name: string }[]>( - 'https://api.github.com/repos/4gray/iptvnator/releases' - ) + .get< + { created_at: string; name: string }[] + >('https://api.github.com/repos/4gray/iptvnator/releases') .pipe( map((response) => { // Filter out pre-release versions (beta, alpha, rc, etc.) @@ -215,10 +218,7 @@ export class SettingsService { * @param latestVersion latest stable version from GitHub * @returns true if current version is outdated */ - isVersionOutdated( - currentVersion: string, - latestVersion: string - ): boolean { + isVersionOutdated(currentVersion: string, latestVersion: string): boolean { const cleanCurrent = parseVersion(currentVersion); const cleanLatest = parseVersion(latestVersion); diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index b4f20bdf0..096db46d9 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -494,7 +494,8 @@ describe('SettingsComponent', () => { .some((player) => player.id === VideoPlayer.EmbeddedMpv) ).toBe(false); - resolveSupport!({ + expect(resolveSupport).toBeDefined(); + resolveSupport({ supported: true, platform: 'darwin', }); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index e2bcb8848..9ea9173e9 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -12,6 +12,7 @@ import { import { FormArray, FormBuilder, + FormControl, ReactiveFormsModule, Validators, } from '@angular/forms'; @@ -48,6 +49,7 @@ import { CoverSize, Language, normalizeExternalPlayerArguments, + Settings, StartupBehavior, StreamFormat, Theme, @@ -162,10 +164,10 @@ export class SettingsComponent implements OnInit, OnDestroy { ]); /** Current version of the app */ - version: string; + version = ''; /** Update message to show */ - updateMessage: string; + updateMessage = ''; /** EPG availability flag */ epgAvailable$ = this.store.select(selectIsEpgAvailable); @@ -178,7 +180,9 @@ export class SettingsComponent implements OnInit, OnDestroy { /** Settings form object */ settingsForm = this.formBuilder.group({ player: [VideoPlayer.VideoJs], - ...(this.isDesktop ? { epgUrl: new FormArray([]) } : {}), + ...(this.isDesktop + ? { epgUrl: new FormArray>([]) } + : {}), streamFormat: StreamFormat.M3u8StreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, @@ -468,21 +472,7 @@ export class SettingsComponent implements OnInit, OnDestroy { * the indexed db store */ onSubmit(): void { - const settings = { - ...this.settingsForm.value, - mpvPlayerPath: this.normalizeExternalPlayerPath( - this.settingsForm.value.mpvPlayerPath - ), - vlcPlayerPath: this.normalizeExternalPlayerPath( - this.settingsForm.value.vlcPlayerPath - ), - mpvPlayerArguments: normalizeExternalPlayerArguments( - this.settingsForm.value.mpvPlayerArguments - ), - vlcPlayerArguments: normalizeExternalPlayerArguments( - this.settingsForm.value.vlcPlayerArguments - ), - }; + const settings = this.createSettingsFromFormValue(); this.settingsStore.updateSettings(settings).then(() => { this.applyChangedSettings(); @@ -505,6 +495,48 @@ export class SettingsComponent implements OnInit, OnDestroy { return playerPath?.trim() ?? ''; } + private createSettingsFromFormValue(): Settings { + const value = this.settingsForm.value; + const epgUrl = Array.isArray(value.epgUrl) + ? value.epgUrl.filter( + (url): url is string => typeof url === 'string' + ) + : []; + + return { + player: value.player ?? VideoPlayer.VideoJs, + streamFormat: value.streamFormat ?? StreamFormat.M3u8StreamFormat, + openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false, + language: value.language ?? Language.ENGLISH, + showCaptions: value.showCaptions ?? false, + showDashboard: value.showDashboard ?? true, + startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView, + showExternalPlaybackBar: value.showExternalPlaybackBar ?? true, + theme: value.theme ?? Theme.SystemTheme, + mpvPlayerPath: this.normalizeExternalPlayerPath( + value.mpvPlayerPath + ), + mpvPlayerArguments: normalizeExternalPlayerArguments( + value.mpvPlayerArguments + ), + mpvReuseInstance: value.mpvReuseInstance ?? false, + vlcPlayerPath: this.normalizeExternalPlayerPath( + value.vlcPlayerPath + ), + vlcPlayerArguments: normalizeExternalPlayerArguments( + value.vlcPlayerArguments + ), + vlcReuseInstance: value.vlcReuseInstance ?? false, + remoteControl: value.remoteControl ?? false, + remoteControlPort: Number(value.remoteControlPort ?? 8765), + recordingFolder: value.recordingFolder ?? '', + coverSize: value.coverSize ?? 'medium', + epgUrl, + preferUploadedEpgOverXtream: + value.preferUploadedEpgOverXtream ?? false, + }; + } + /** * Applies the changed settings to the app */ @@ -516,14 +548,19 @@ export class SettingsComponent implements OnInit, OnDestroy { if (!Array.isArray(epgUrls)) { epgUrls = [epgUrls]; } - epgUrls = epgUrls.filter((url) => url !== ''); - if (epgUrls.length > 0) { + const validEpgUrls = epgUrls.filter( + (url): url is string => + typeof url === 'string' && url !== '' + ); + if (validEpgUrls.length > 0) { // Fetch all EPG URLs at once - this.epgService.fetchEpg(epgUrls); + this.epgService.fetchEpg(validEpgUrls); } } } - this.translate.use(this.settingsForm.value.language); + this.translate.use( + this.settingsForm.value.language ?? Language.ENGLISH + ); this.settingsService.changeTheme( this.settingsForm.value.theme ?? Theme.SystemTheme ); diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 34d49ea50..1297fc31f 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -19,6 +19,7 @@ import { PlaylistRefreshPayload, PortalDebugEvent, ResolvedPortalPlayback, + Settings, XtreamBackupFavoriteItem, XtreamBackupHiddenCategory, XtreamBackupRecentlyViewedItem, @@ -119,7 +120,7 @@ declare global { searchTerm: string, limit?: number ) => Promise; - updateSettings: (settings: JsonObject) => Promise; + updateSettings: (settings: Partial) => Promise; getAiSettings: () => Promise<{ aiProvider: string; aiModelName: string; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b17319d3b..83086f821 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -13,7 +13,7 @@ "typeRoots": ["../../node_modules/@types"], "lib": ["es2018", "es2017", "es2016", "es2015", "dom"], "resolveJsonModule": true, - "strict": false, + "strict": true, "skipLibCheck": true, "useDefineForClassFields": false }, diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index c78c7c66f..6bb2389e3 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -109,7 +109,7 @@ [disabled]="!textImport()?.textForm?.valid" (click)=" textImport()?.textAdded?.emit( - textImport()?.textForm?.value?.text + textImport()?.textForm?.value?.text ?? undefined ) " > diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index a812afa05..266a7b40b 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -112,11 +112,11 @@ export class StalkerPortalImportComponent { const isFullStalkerPortal = this.isFullStalkerPortalUrl(originalUrl); const stalkerIdentity = normalizeStalkerPortalIdentity({ - serialNumber: formValue.serialNumber, - deviceId1: formValue.deviceId1, - deviceId2: formValue.deviceId2, - signature1: formValue.signature1, - signature2: formValue.signature2, + serialNumber: formValue.serialNumber ?? undefined, + deviceId1: formValue.deviceId1 ?? undefined, + deviceId2: formValue.deviceId2 ?? undefined, + signature1: formValue.signature1 ?? undefined, + signature2: formValue.signature2 ?? undefined, }); let stalkerToken: string | undefined; @@ -151,7 +151,7 @@ export class StalkerPortalImportComponent { ); this.snackBar.open( `Portal validated. Expires: ${expireDate.toLocaleDateString()}`, - null, + undefined, { duration: 3000 } ); } @@ -162,7 +162,7 @@ export class StalkerPortalImportComponent { ); this.snackBar.open( 'Failed to authenticate with portal. Please check URL and MAC address.', - null, + undefined, { duration: 5000 } ); this.isLoading.set(false); diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html index 897bb428c..dc30f54d8 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html @@ -14,7 +14,7 @@ type="text" id="serverUrl" formControlName="serverUrl" - (input)="extractParams(form.value.serverUrl)" + (input)="extractParams(form.value.serverUrl ?? '')" /> {{ 'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index c0a850697..ef3f7e670 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -155,8 +155,8 @@ export class XtreamCodeImportComponent { extractParams(urlAsString: string): void { if ( - this.form.get('username').value !== '' || - this.form.get('password').value !== '' + this.form.get('username')?.value !== '' || + this.form.get('password')?.value !== '' ) return; try { diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 44775c432..fe3dae062 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -411,8 +411,16 @@ export class PlaylistSwitcherComponent { this.portalStatusAbortController = controller; const xtreamPlaylists = playlists.filter( - (playlist) => - playlist.serverUrl && playlist.username && playlist.password + ( + playlist + ): playlist is PlaylistMeta & { + serverUrl: string; + username: string; + password: string; + } => + Boolean( + playlist.serverUrl && playlist.username && playlist.password + ) ); if (xtreamPlaylists.length === 0) { return; @@ -422,7 +430,11 @@ export class PlaylistSwitcherComponent { // 'checking' in a single signal write so the UI flips from blank → // cached/checking dots in one render, not one per playlist. const next = new Map(this.portalStatuses()); - const toFetch: PlaylistMeta[] = []; + const toFetch: (PlaylistMeta & { + serverUrl: string; + username: string; + password: string; + })[] = []; for (const playlist of xtreamPlaylists) { const cached = this.portalStatusService.getCachedStatus( playlist.serverUrl, diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 7743a3277..3466dbe05 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -460,7 +460,7 @@ export class RecentPlaylistsComponent { this.translate.instant( 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' ), - null, + undefined, { duration: 2000 } ); } catch (error) { diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index 25edc527c..6fd9b69e1 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -15,7 +15,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { DialogService } from '@iptvnator/ui/components'; -import { firstValueFrom, map } from 'rxjs'; +import { firstValueFrom, map, startWith } from 'rxjs'; import { DatabaseService, type DownloadItem, @@ -83,16 +83,22 @@ export class DownloadsComponent { readonly searchTerm = queryParamSignal(this.route, 'q', (value) => (value ?? '').trim().toLowerCase() ); - readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), { - initialValue: null as Playlist[] | null, - }); + readonly playlists = toSignal( + this.playlistsService.getAllPlaylists().pipe(startWith(null)), + { + initialValue: null as Playlist[] | null, + } + ); readonly playlistsLoaded = computed(() => this.playlists() !== null); readonly playlistItems = computed(() => this.playlists() ?? []); readonly hasNoPlaylists = computed( () => this.playlistsLoaded() && this.playlistItems().length === 0 ); readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index); - readonly skeletonActionSlots = Array.from({ length: 4 }, (_, index) => index); + readonly skeletonActionSlots = Array.from( + { length: 4 }, + (_, index) => index + ); addPlaylist(): void { this.shellActions.openAddPlaylistDialog(); @@ -275,7 +281,9 @@ export class DownloadsComponent { async reveal(item: DownloadItem) { if (item.filePath) { - const result = await this.downloadsService.revealFile(item.filePath); + const result = await this.downloadsService.revealFile( + item.filePath + ); if (!result.success) { this.handleFileActionError(result.error); } @@ -309,7 +317,10 @@ export class DownloadsComponent { } hasPoster(item: DownloadItem): boolean { - return !!item.posterUrl && !this.failedPosterKeys()[this.getPosterKey(item)]; + return ( + !!item.posterUrl && + !this.failedPosterKeys()[this.getPosterKey(item)] + ); } markPosterFailed(item: DownloadItem): void { diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index ee40affa2..2ab10870e 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -32,7 +32,7 @@ import { const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; -type PlaylistWithChannels = Playlist & { +type PlaylistWithChannels = Omit & { readonly playlist?: { readonly items?: Channel[] }; }; type StalkerPortalFavoriteItem = StalkerPortalItem & { diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 163c59653..cb743c8e4 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -31,7 +31,7 @@ import { XtreamContentItem, } from '@iptvnator/portal/xtream/data-access'; -type PlaylistWithChannels = Playlist & { +type PlaylistWithChannels = Omit & { readonly playlist?: { readonly items?: Channel[] }; }; diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html index 10dfcd771..cbce1c381 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html @@ -1,4 +1,4 @@ -@if (items()?.length > 0) { +@if (items().length > 0) { - @if (showPaginator() && items()?.length > 0) { + @if (showPaginator() && items().length > 0) { (); - readonly isLoading = input(); + readonly items = input([]); + readonly isLoading = input(false); readonly showPaginator = input(true); readonly searchTerm = input(''); readonly itemClicked = output(); readonly pageChange = output(); - readonly pageIndex = input(); - readonly totalPages = input(); - readonly limit = input(); - readonly pageSizeOptions = input(); + readonly pageIndex = input(0); + readonly totalPages = input(0); + readonly limit = input(25); + readonly pageSizeOptions = input([]); protected readonly resolveRating = resolveGridRating; protected readonly hasActiveSearch = computed( () => (this.searchTerm() ?? '').trim().length > 0 diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts index 387f32887..864100c3a 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts @@ -26,10 +26,10 @@ export class PlaylistErrorViewComponent { private readonly currentPlaylist = this.playlistContext.activePlaylist; - readonly description = input(undefined); + readonly description = input(undefined); readonly showIllustration = input(true); readonly showActionButtons = input(true); - readonly title = input(undefined); + readonly title = input(undefined); readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>( 'ERROR' ); diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 8f93b093f..f4b68cd9b 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -49,7 +49,7 @@ export function getRecentItemNavigation( if (item.type === 'live') { return buildLiveCollectionNavigationTarget({ mode: 'recent', - sourceType: item.source, + sourceType: normalizeCollectionSourceType(item.source) ?? 'm3u', playlistId: item.playlist_id, itemId: item.xtream_id ?? item.id, title: item.title, @@ -91,7 +91,7 @@ export function getGlobalFavoriteNavigation( if (item.type === 'live') { return buildLiveCollectionNavigationTarget({ mode: 'favorites', - sourceType: item.source, + sourceType: normalizeCollectionSourceType(item.source) ?? 'm3u', playlistId: item.playlist_id, itemId: item.xtream_id ?? item.id, title: item.title, @@ -231,7 +231,7 @@ export function buildOpenCollectionDetailItemState( item: UnifiedCollectionItem ): OpenCollectionDetailItemState { return { - item: normalizeCollectionDetailItem(item), + item: normalizeCollectionDetailItem(item) ?? item, }; } @@ -539,6 +539,14 @@ function normalizeCollectionScope( return value === 'playlist' || value === 'all' ? value : null; } +function normalizeCollectionSourceType( + value: unknown +): CollectionSourceType | null { + return value === 'm3u' || value === 'xtream' || value === 'stalker' + ? value + : null; +} + function buildDashboardCollectionDetailItem( item: PortalFavoriteItem | PortalRecentItem ): UnifiedCollectionItem | null { @@ -669,9 +677,7 @@ function normalizeCollectionDetailItem( } const contentType = toPathSegment(record['contentType']); - const sourceType = toPathSegment( - record['sourceType'] - ) as CollectionSourceType; + const sourceType = normalizeCollectionSourceType(record['sourceType']); const uid = toPathSegment(record['uid']); const name = toPathSegment(record['name']); const playlistId = toPathSegment(record['playlistId']); @@ -680,6 +686,7 @@ function normalizeCollectionDetailItem( !uid || !name || !playlistId || + !sourceType || (contentType !== 'movie' && contentType !== 'series') || (sourceType !== 'xtream' && sourceType !== 'stalker') ) { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 8368691ec..28a703c90 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -8,10 +8,7 @@ import { type StalkerPortalIdentity, } from './stalker-identity.utils'; -export { - getStalkerPortalIdentityFromPlaylist, - normalizeStalkerPortalIdentity, -}; +export { getStalkerPortalIdentityFromPlaylist, normalizeStalkerPortalIdentity }; export type { StalkerPortalIdentity }; /** @@ -100,7 +97,10 @@ export class StalkerSessionService { string, Promise<{ token: string; serialNumber?: string }> >(); - private watchdogIntervals = new Map>(); + private watchdogIntervals = new Map< + string, + ReturnType + >(); private watchdogPlaylists = new Map(); private watchdogInFlight = new Set(); private activeWatchdogPlaylistId: string | null = null; @@ -494,14 +494,16 @@ export class StalkerSessionService { console.error('[StalkerSession] Missing portal URL or MAC address'); throw new Error('Portal URL and MAC address are required'); } + const portalUrl = playlist.portalUrl; + const macAddress = playlist.macAddress; // Create the authentication promise and store it to prevent concurrent auth attempts // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { try { const { token } = await this.authenticate( - playlist.portalUrl, - playlist.macAddress, + portalUrl, + macAddress, identity ); this.setCachedToken(playlist._id, token); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index b0bc3d68e..1fcb7b1f8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -94,9 +94,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy { const item = this.selectedItem(); return Boolean( item && - (this.contentType() === 'series' || - item.is_series === true || - String(item.is_series) === '1') + (this.contentType() === 'series' || + item.is_series === true || + String(item.is_series) === '1') ); }); @@ -257,7 +257,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { showCopyNotification(): void { this.snackBar.open( this.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, + undefined, { duration: 2000, } @@ -381,7 +381,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { 'PORTALS.CONTENT_NOT_AVAILABLE' ) : this.translateService.instant('PORTALS.PLAYBACK_ERROR'); - this.snackBar.open(errorMessage, null, { + this.snackBar.open(errorMessage, undefined, { duration: 3000, }); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index ccdadf6e8..9618de29b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -27,8 +27,7 @@ function calculateProgress(position: PlaybackPositionData | undefined): number { return 0; } - const percent = - (position.positionSeconds / position.durationSeconds) * 100; + const percent = (position.positionSeconds / position.durationSeconds) * 100; if (position.positionSeconds > 10 && percent < 1) { return 1; @@ -38,20 +37,17 @@ function calculateProgress(position: PlaybackPositionData | undefined): number { } @Injectable() -export class StalkerCatalogFacadeService - implements - StalkerPortalCatalogFacade< - Record, - StalkerVodSource, - StalkerVodSource - > -{ +export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< + Record, + StalkerVodSource, + StalkerVodSource +> { private readonly stalkerStore = inject(StalkerStore); private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); private readonly destroyRef = inject(DestroyRef); - private readonly stalkerPositions = signal>( - new Map() - ); + private readonly stalkerPositions = signal< + Map + >(new Map()); private readonly stalkerSeriesPositions = signal< Map >(new Map()); @@ -72,9 +68,9 @@ export class StalkerCatalogFacadeService this.stalkerStore.isPaginatedContentLoading; readonly selectedCategoryTitle = computed(() => { const category = this.selectedCategory(); - const fromCategory = String( - category?.['category_name'] ?? category?.['name'] ?? '' - ); + const fromCategory = category + ? String(category.category_name ?? '') + : ''; if (fromCategory) { return fromCategory; @@ -125,7 +121,10 @@ export class StalkerCatalogFacadeService if (window.electron?.onPlaybackPositionUpdate) { const unsubscribe = window.electron.onPlaybackPositionUpdate( (data: PlaybackPositionData) => { - if (data.playlistId !== this.playlist()?.id) { + if ( + !data.playlistId || + data.playlistId !== this.playlist()?.id + ) { return; } @@ -138,10 +137,7 @@ export class StalkerCatalogFacadeService this.updateVodPlaybackPosition(data); } - if ( - data.contentType === 'episode' && - data.seriesXtreamId - ) { + if (data.contentType === 'episode' && data.seriesXtreamId) { this.updateSeriesPlaybackPosition(data); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 785376dac..5f8295d59 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -181,7 +181,7 @@ export class StalkerCollectionDetailComponent { this.originalState.selectedContentType ); this.stalkerStore.setSelectedCategory( - this.originalState.selectedCategoryId + this.originalState.selectedCategoryId ?? null ); this.stalkerStore.setSelectedItem( this.originalState.selectedItem as never diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts index 03eb10750..315f93494 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts @@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common'; import { Component, inject, input } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; -import { BehaviorSubject, map, switchMap } from 'rxjs'; +import { BehaviorSubject, map, of, switchMap } from 'rxjs'; import { PlaylistsService } from '@iptvnator/services'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { @@ -33,11 +33,12 @@ export class FavoritesButtonComponent { private readonly favoritesChanged$ = new BehaviorSubject(undefined); readonly isFavorite$ = this.favoritesChanged$.pipe( - switchMap(() => - this.playlistService.getPortalFavorites( - this.stalkerStore.currentPlaylist()?._id - ) - ), + switchMap(() => { + const playlistId = this.stalkerStore.currentPlaylist()?._id; + return playlistId + ? this.playlistService.getPortalFavorites(playlistId) + : of([]); + }), map((favorites) => favorites.some((favorite) => matchesFavoriteById(favorite, this.itemId()) diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index fa933a512..16cdd2eff 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -8,10 +8,10 @@ } @else if (isSeries()) { - } @else if (vodDetailsItem()) { + } @else if (vodDetailsItem(); as item) { { - favs.forEach((fav: StalkerFavoriteItem) => { - if (fav.id !== undefined) { - this.favorites.set( - normalizeStalkerEntityId(fav.id), - true - ); - } + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if (playlistId) { + this.playlistService + .getPortalFavorites(playlistId) + .pipe(takeUntilDestroyed()) + .subscribe((favs) => { + favs.forEach((fav: StalkerFavoriteItem) => { + if (fav.id !== undefined) { + this.favorites.set( + normalizeStalkerEntityId(fav.id), + true + ); + } + }); }); - }); + } // Reset channels/page on category change effect(() => { @@ -427,10 +436,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.logger.error('Playback failed', error); const errorMessage = - error?.message === 'nothing_to_play' + error instanceof Error && error.message === 'nothing_to_play' ? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE') : this.translate.instant('PORTALS.PLAYBACK_ERROR'); - this.snackBar.open(errorMessage, null, { duration: 3000 }); + this.snackBar.open(errorMessage, undefined, { duration: 3000 }); } } @@ -446,9 +455,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return this.playbackResolution.promise; } + const playableItem = this.toPlayableChannel(item); const promise = this.isRadioMode() - ? this.stalkerStore.resolveRadioPlayback(item) - : this.stalkerStore.resolveItvPlayback(item); + ? this.stalkerStore.resolveRadioPlayback(playableItem) + : this.stalkerStore.resolveItvPlayback(playableItem); this.playbackResolution = { channelId: playbackChannelId, promise }; const cleanup = () => { @@ -468,8 +478,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.removeFromFavorites(itemId); this.favorites.delete(itemId); } else { + const playableItem = this.toPlayableChannel(item); this.stalkerStore.addToFavorites({ - ...item, + ...playableItem, category_id: this.isRadioMode() ? 'radio' : 'itv', title: item.o_name || item.name, cover: item.logo, @@ -620,6 +631,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const nowMs = Date.now(); if ( + startMs !== null && + stopMs !== null && Number.isFinite(startMs) && Number.isFinite(stopMs) && nowMs >= startMs && @@ -636,6 +649,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.currentProgramsProgress.delete(channelId); } + private toPlayableChannel(item: StalkerItvChannel): StalkerPlayableChannel { + const { is_series, ...rest } = item; + return is_series == null ? rest : { ...rest, is_series }; + } + private setupScrollListener() { this.removeScrollListener(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index 985dbfb51..7284f767f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -23,7 +23,9 @@ (inlineTimeUpdated)="handleInlineTimeUpdate($event)" (inlinePlaybackClosed)="closeInlinePlayer()" (streamUrlCopied)="showCopyNotification()" - (inlineExternalFallbackRequested)="handleExternalFallbackRequest($event)" + (inlineExternalFallbackRequested)=" + handleExternalFallbackRequest($event) + " (favoriteToggled)="onVodFavoriteToggled($event)" (backClicked)="onVodBack()" /> @@ -46,7 +48,7 @@
@for (item of searchResultsResource.value(); track item.id) { >({ series: false, vod: true, }); @@ -127,7 +129,7 @@ export class StalkerSearchComponent { return playlist?.macAddress ? playlist : null; }); - readonly selectedFilterType = signal('vod'); + readonly selectedFilterType = signal('vod'); private readonly favoritesRefresh = createRefreshTrigger(); itemDetails: StalkerSelectedVodItem | null = null; @@ -167,6 +169,10 @@ export class StalkerSearchComponent { const playlist = this.currentPlaylist(); if (!playlist) return []; const { portalUrl, macAddress } = playlist; + if (!portalUrl || !macAddress) { + return []; + } + const contentType = params.contentType; let token: string | undefined; let serialNumber: string | undefined; @@ -189,9 +195,9 @@ export class StalkerSearchComponent { url: portalUrl, macAddress, params: { - action: StalkerContentTypes[params.contentType] + action: StalkerContentTypes[contentType] .getContentAction, - type: params.contentType, + type: contentType, search: params.search, max_page_items: 100, }, @@ -199,16 +205,10 @@ export class StalkerSearchComponent { serialNumber, } ); - if (response) { - const items = response.js?.data || []; - return items.map((item: StalkerVodSource) => - this.processItemUrls(item, portalUrl) - ); - } else { - throw new Error( - `Error: ${response.message} (Status: ${response.status})` - ); - } + const items = response.js?.data || []; + return items.map((item: StalkerVodSource) => + this.processItemUrls(item, portalUrl) + ); }, }); @@ -243,23 +243,26 @@ export class StalkerSearchComponent { this.searchTerm.set(term); } - updateFilter(key: string, value: boolean) { + updateFilter(key: StalkerSearchContentType, value: boolean) { if (value) { // Single selection mode - set clicked filter, disable others this.selectedFilterType.set(key); - this.filters.update((f) => { - const newFilters: Record = {}; - Object.keys(f).forEach((k) => { - newFilters[k] = k === key; + this.filters.update(() => { + const newFilters: Record = { + series: false, + vod: false, + }; + this.filterConfig.forEach((filter) => { + newFilters[filter.key] = filter.key === key; }); - return newFilters as typeof f; + return newFilters; }); } } selectItem(item: StalkerVodSource) { this.closeInlinePlayer(); - const hasEmbeddedSeries = item.series?.length > 0; + const hasEmbeddedSeries = (item.series?.length ?? 0) > 0; const needsSeriesFetch = this.selectedFilterType() === 'vod' && !hasEmbeddedSeries && diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 7bcb88687..25e2a5d69 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -8,14 +8,10 @@ @if (serial.info.releasedate) { - {{ - serial.info.releasedate - }} + {{ serial.info.releasedate }} } @if (serial.info.genre) { - {{ - serial.info.genre - }} + {{ serial.info.genre }} } @if (serial.info.rating_imdb) { @@ -33,9 +29,7 @@ @if (serial.info.actors) {
- {{ - 'XTREAM.ACTORS' | translate - }} + {{ 'XTREAM.ACTORS' | translate }} {{ serial.info.actors }}
} @@ -75,10 +69,7 @@
} - +
@if (inlinePlayback(); as playback) { @@ -99,19 +90,20 @@ [seasons]="mappedSeasons()" [seriesId]="toSeriesId(serial.id)" [playlistId]="stalkerStore.currentPlaylist()?._id ?? ''" - [seriesTitle]="serial.info?.name" + [seriesTitle]="serial.info.name" [playbackPositions]="episodePlaybackPositions()" [openingEpisodeId]="openingEpisodeId()" [activeEpisodeId]="activeEpisodeId()" [isLoading]=" isVodSeriesSeasonsLoading() || - (seasonContainer?.selectedSeason && - isCurrentSeasonLoading(seasonContainer.selectedSeason)) + isCurrentSeasonLoading(seasonContainer?.selectedSeason) " (seasonSelected)="onSeasonSelected($event)" (episodeClicked)="onEpisodeClicked($event)" (episodeDownloadRequested)="downloadEpisode($event)" - (playbackToggleRequested)="handlePlaybackToggleRequested($event)" + (playbackToggleRequested)=" + handlePlaybackToggleRequested($event) + " #seasonContainer /> } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 1df5faad2..2b6a8276f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -334,7 +334,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { /** * Determines if the current selected season is loading */ - isCurrentSeasonLoading(seasonKey: string): boolean { + isCurrentSeasonLoading(seasonKey?: string): boolean { + if (!seasonKey) return false; if (!this.isVodSeries()) return false; const season = this.vodSeriesSeasons().find( (s) => getVodSeriesSeasonKey(s) === seasonKey @@ -498,7 +499,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { showCopyNotification(): void { this.snackBar.open( this.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, + undefined, { duration: 2000, } @@ -546,7 +547,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { 'PORTALS.CONTENT_NOT_AVAILABLE' ) : this.translateService.instant('PORTALS.PLAYBACK_ERROR'); - this.snackBar.open(errorMessage, null, { + this.snackBar.open(errorMessage, undefined, { duration: 3000, }); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index 27bc985f3..ed1f5df40 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -56,7 +56,7 @@ export class StalkerVodPlaybackController { : this.config.translateService.instant( 'PORTALS.PLAYBACK_ERROR' ); - this.config.snackBar.open(errorMessage, null, { + this.config.snackBar.open(errorMessage, undefined, { duration: 3000, }); } @@ -122,7 +122,7 @@ export class StalkerVodPlaybackController { showCopyNotification(): void { this.config.snackBar.open( this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, + undefined, { duration: 2000, } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index e2eabcedc..a2365a76b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -360,7 +360,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { ): Promise { return this.dbService.saveXtreamContent( playlistId, - streams, + streams as Parameters[1], type, onProgress, options diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 4c07c657c..0d0e5606b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -616,8 +616,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Sort by updatedAt descending playlistPositions.sort( (a, b) => - new Date(b.updatedAt).getTime() - - new Date(a.updatedAt).getTime() + new Date(b.updatedAt ?? '').getTime() - + new Date(a.updatedAt ?? '').getTime() ); return limit ? playlistPositions.slice(0, limit) : playlistPositions; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts index 27fd022b5..199dd2e7d 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts @@ -6,7 +6,10 @@ import { withState, } from '@ngrx/signals'; import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; -import { XtreamSerieEpisode, XtreamVodDetails } from '@iptvnator/shared/interfaces'; +import { + XtreamSerieEpisode, + XtreamVodDetails, +} from '@iptvnator/shared/interfaces'; import { XtreamCredentials } from '../../services/xtream-api.service'; import { XtreamUrlService } from '../../services/xtream-url.service'; @@ -80,10 +83,11 @@ export function withPlayer() { /** * Helper to get playlist with headers from parent store */ - const getPlaylistFromStore = (): XtreamPlaylistHeadersLike | null => { - const storeAny = store as ParentPlayerStoreLike; - return storeAny.currentPlaylist?.(); - }; + const getPlaylistFromStore = + (): XtreamPlaylistHeadersLike | null => { + const storeAny = store as ParentPlayerStoreLike; + return storeAny.currentPlaylist?.() ?? null; + }; return { /** diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index 8d7fda351..ba60a3204 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -51,7 +51,7 @@ function mapDbRecentItem( export const withRecentItems = function () { const logger = createLogger('withRecentItems'); return signalStoreFeature( - withState({ + withState<{ recentItems: RecentlyViewedItem[] }>({ recentItems: [], }), withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({ @@ -81,7 +81,7 @@ export const withRecentItems = function () { addRecentItem: rxMethod<{ xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; - playlist: Signal<{ id: string }>; + playlist: Signal<{ id: string } | null | undefined>; backdropUrl?: string; }>( pipe( @@ -92,7 +92,7 @@ export const withRecentItems = function () { playlist, backdropUrl, }) => { - const playlistId = playlist().id; + const playlistId = playlist()?.id; const normalizedXtreamId = Number(xtreamId); if ( !playlistId || @@ -145,10 +145,10 @@ export const withRecentItems = function () { }: { xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; - playlist: Signal<{ id: string }>; + playlist: Signal<{ id: string } | null | undefined>; backdropUrl?: string; }): Promise { - const playlistId = playlist().id; + const playlistId = playlist()?.id; const normalizedXtreamId = Number(xtreamId); const normalizedBackdropUrl = backdropUrl?.trim(); if ( diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index f25a16c8b..13974df87 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -26,7 +26,7 @@ trackBy: trackBy; templateCacheSize: 0 " - [name]="item.title || item.name" + [name]="item.title || item.name || ''" [logo]="item.stream_icon ?? item.poster_url" [selected]=" xtreamStore.selectedItem()?.xtream_id === item.xtream_id diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html index de551d98f..ef5334448 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html @@ -32,7 +32,7 @@ > @for (item of recentlyAddedVod(); track item.xtream_id) { @for (item of recentlyAddedSeries(); track item.xtream_id) { @for (item of recentlyAddedLive(); track item.xtream_id) { { - return items.reduce>( - (groups, item) => { - const key = String(item.playlist_name); - if (!groups[key]) { - groups[key] = []; - } - groups[key].push(item); - return groups; - }, - {} - ); + return items.reduce>((groups, item) => { + const key = String(item.playlist_name); + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(item); + return groups; + }, {}); } @Component({ @@ -199,7 +196,7 @@ export class SearchResultsComponent implements AfterViewInit { } ngAfterViewInit() { - this.xtreamStore.setSelectedContentType(undefined); + this.xtreamStore.setSelectedContentType('vod'); setTimeout(() => { this.searchLayoutComponent()?.focusSearchInput(); }); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index cd8558b1f..ad0ab5e9b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -1,46 +1,47 @@ @let item = selectedItem(); -@if (item?.info) { +@if (item && item.info) { + @let info = item.info; - @if (item.info?.releaseDate) { + @if (info.releaseDate) { {{ - item.info.releaseDate | slice: 0 : 4 + info.releaseDate | slice: 0 : 4 }} } - @if (item.info?.genre) { - {{ item.info.genre }} + @if (info.genre) { + {{ info.genre }} } - @if (item.info?.episode_run_time) { + @if (info.episode_run_time) { {{ item.info.episode_run_time }} min/ep{{ info.episode_run_time }} min/ep } - @if (item.info?.rating) { + @if (info.rating) { - ⭐ {{ item.info.rating }} + ⭐ {{ info.rating }} } - @if (item.info?.cast) { + @if (info.cast) {
{{ 'XTREAM.CAST' | translate }} - {{ item.info.cast }} + {{ info.cast }}
} - @if (item.info?.director) { + @if (info.director) {
{{ 'XTREAM.DIRECTOR' | translate }} - {{ item.info.director }} + {{ info.director }}
}
@@ -100,7 +101,7 @@ [seasons]="item.episodes" [seriesId]="item.series_id" [playlistId]="currentPlaylistId()" - [seriesTitle]="item.info?.name" + [seriesTitle]="info.name ?? ''" [playbackPositions]="episodePlaybackPositions()" [xtreamDownloadContext]="xtreamDownloadContext()" [openingEpisodeId]="openingEpisodeId()" @@ -111,7 +112,7 @@
} @else if (detailsError()) { } @else { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index d67e95fdc..e7890f92c 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -154,7 +154,12 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { const xtreamId = Number(selectedItem?.series_id ?? 0); const backdropUrl = selectedItem?.info?.backdrop_path?.[0]?.trim(); - if (!playlistId || !Number.isFinite(xtreamId) || xtreamId <= 0 || !backdropUrl) { + if ( + !playlistId || + !Number.isFinite(xtreamId) || + xtreamId <= 0 || + !backdropUrl + ) { return; } @@ -246,14 +251,20 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { } playEpisode(episode: XtreamSerieEpisode): void { + const playlist = this.xtreamStore.currentPlaylist(); + const selectedItem = this.selectedItem(); + if (!playlist || !selectedItem) { + return; + } + this.addToRecentlyViewed(this.route.snapshot.params.serialId); const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode); const contentInfo: PlayerContentInfo = { - playlistId: this.xtreamStore.currentPlaylist().id, + playlistId: playlist.id, contentXtreamId: Number(episode.id), contentType: 'episode', - seriesXtreamId: Number(this.selectedItem().series_id), + seriesXtreamId: Number(selectedItem.series_id), seasonNumber: Number(episode.season), episodeNumber: Number(episode.episode_num), }; @@ -265,7 +276,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { const playback: ResolvedPortalPlayback = { streamUrl, title: episode.title, - thumbnail: this.selectedItem().info.cover, + thumbnail: selectedItem.info.cover, startTime: position?.positionSeconds, contentInfo, }; @@ -283,9 +294,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { } toggleFavorite(): void { + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + this.xtreamStore.toggleFavorite( this.route.snapshot.params.serialId, - this.xtreamStore.currentPlaylist().id, + playlist.id, 'series', this.selectedItem()?.info?.backdrop_path?.[0] ); @@ -327,7 +343,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { showCopyNotification(): void { this.snackBar.open( this.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, + undefined, { duration: 2000, } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 9a8d1c12b..b13bbd318 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -60,8 +60,12 @@ class="play-btn play-btn--resume" (click)="onPrimaryAction(item)" [disabled]="isExternalLaunchPending()" - [class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'" - [class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'" + [class.play-btn--state-launching]=" + externalPrimaryButtonState() === 'launching' + " + [class.play-btn--state-stop]=" + externalPrimaryButtonState() === 'stop' + " > } @else if (detailsError()) { } @else { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index b094498e7..27c728178 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -98,21 +98,28 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { .find( (category) => String( - (category as XtreamCategory & { id?: string | number }) - .category_id ?? - (category as XtreamCategory & { id?: string | number }) - .id + ( + category as XtreamCategory & { + id?: string | number; + } + ).category_id ?? + ( + category as XtreamCategory & { + id?: string | number; + } + ).id ) === String(categoryId) ) ?? null ); }); readonly selectedCatalogItem = computed< - (Partial & { - id?: string | number; - poster_url?: string; - title?: string; - xtream_id?: string | number; - }) | null + | (Partial & { + id?: string | number; + poster_url?: string; + title?: string; + xtream_id?: string | number; + }) + | null >(() => { const vodId = this.selectedVodId(); if (!Number.isFinite(vodId) || vodId <= 0) { @@ -120,29 +127,27 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } return ( - this.xtreamStore - .vodStreams() - .find( - (item) => - Number( + this.xtreamStore.vodStreams().find( + (item) => + Number( + ( + item as XtreamVodStream & { + id?: string | number; + xtream_id?: string | number; + } + ).xtream_id ?? ( item as XtreamVodStream & { id?: string | number; - xtream_id?: string | number; } - ).xtream_id ?? - ( - item as XtreamVodStream & { - id?: string | number; - } - ).stream_id ?? - ( - item as XtreamVodStream & { - id?: string | number; - } - ).id - ) === vodId - ) ?? null + ).stream_id ?? + ( + item as XtreamVodStream & { + id?: string | number; + } + ).id + ) === vodId + ) ?? null ); }); readonly selectedVodInfo = computed(() => { @@ -278,9 +283,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { effect(() => { const playlistId = this.xtreamStore.currentPlaylist()?.id; const vodId = Number(this.route.snapshot.params.vodId); - const backdropUrl = this.selectedVodInfo()?.backdrop_path?.[0]?.trim(); + const backdropUrl = + this.selectedVodInfo()?.backdrop_path?.[0]?.trim(); - if (!playlistId || !Number.isFinite(vodId) || vodId <= 0 || !backdropUrl) { + if ( + !playlistId || + !Number.isFinite(vodId) || + vodId <= 0 || + !backdropUrl + ) { return; } @@ -302,7 +313,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.unsubscribePositionUpdates = window.electron.onPlaybackPositionUpdate( (data: PlaybackPositionData) => { - const playlistId = this.xtreamStore.currentPlaylist()?.id; + const playlistId = + this.xtreamStore.currentPlaylist()?.id; const vodId = Number(this.route.snapshot.params.vodId); if ( @@ -325,7 +337,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.logger.warn('Deferring VOD details init: playlist not ready'); return; } - this.initializeVodDetails(currentPlaylist.id, Number(this.route.snapshot.params.vodId)); + this.initializeVodDetails( + currentPlaylist.id, + Number(this.route.snapshot.params.vodId) + ); this.detailsInitDone.set(true); } @@ -335,7 +350,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.xtreamStore.setSelectedItem(null); } - playVod(vodItem: XtreamVodDetails): void { + playVod(vodItem: XtreamVodDetails | null): void { + if (!vodItem) { + return; + } + + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + const info = getXtreamVodInfo(vodItem); this.addToRecentlyViewed(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); @@ -350,13 +374,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.logger.debug('playVod resolved ID', { id, vodItem }); const contentInfo: PlayerContentInfo = { - playlistId: this.xtreamStore.currentPlaylist().id, + playlistId: playlist.id, contentXtreamId: id, contentType: 'vod', }; const playback: ResolvedPortalPlayback = { streamUrl, - title: info?.name ?? vodItem.movie_data?.name, + title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', thumbnail: info?.movie_image, contentInfo, }; @@ -364,7 +388,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.startPlayback(playback); } - resumeVod(vodItem: XtreamVodDetails): void { + resumeVod(vodItem: XtreamVodDetails | null): void { + if (!vodItem) { + return; + } + + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + const info = getXtreamVodInfo(vodItem); this.addToRecentlyViewed(); const vodId = Number(this.route.snapshot.params.vodId); @@ -372,13 +405,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); const contentInfo: PlayerContentInfo = { - playlistId: this.xtreamStore.currentPlaylist().id, + playlistId: playlist.id, contentXtreamId: vodId, contentType: 'vod', }; const playback: ResolvedPortalPlayback = { streamUrl, - title: info?.name ?? vodItem.movie_data?.name, + title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', thumbnail: info?.movie_image, startTime: position?.positionSeconds, contentInfo, @@ -387,7 +420,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.startPlayback(playback); } - onPrimaryAction(vodItem: XtreamVodDetails): void { + onPrimaryAction(vodItem: XtreamVodDetails | null): void { + if (!vodItem) { + return; + } + if (this.isExternalStopAction()) { void this.stopExternalPlayback(); return; @@ -402,7 +439,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } async stopExternalPlayback(): Promise { - await this.externalPlayback.closeSession(this.matchedExternalPlayback()); + await this.externalPlayback.closeSession( + this.matchedExternalPlayback() + ); } formatPosition(): string { @@ -412,15 +451,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { const date = new Date(0); date.setSeconds(position.positionSeconds); const timeString = date.toISOString().substr(11, 8); - return timeString.startsWith('00:') - ? timeString.substr(3) - : timeString; + return timeString.startsWith('00:') ? timeString.substr(3) : timeString; } toggleFavorite(): void { + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + this.xtreamStore.toggleFavorite( this.route.snapshot.params.vodId, - this.xtreamStore.currentPlaylist().id, + playlist.id, 'movie', this.selectedVodInfo()?.backdrop_path?.[0] ); @@ -462,7 +504,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { showCopyNotification(): void { this.snackBar.open( this.translateService.instant('PORTALS.STREAM_URL_COPIED'), - null, + undefined, { duration: 2000, } @@ -476,7 +518,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ); } - async downloadVod(vodItem: XtreamVodDetails): Promise { + async downloadVod(vodItem: XtreamVodDetails | null): Promise { + if (!vodItem) { + return; + } + const info = getXtreamVodInfo(vodItem); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); const routeVodId = this.route.snapshot.params.vodId; @@ -488,6 +534,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ); const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } await this.downloadsService.startDownload({ playlistId: playlist.id, diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts index 815053f34..737acfa7c 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -41,7 +41,7 @@ interface XtreamCollectionStateSnapshot { } @else { diff --git a/libs/services/src/lib/data.service.ts b/libs/services/src/lib/data.service.ts index ed23adffc..a01e6f383 100644 --- a/libs/services/src/lib/data.service.ts +++ b/libs/services/src/lib/data.service.ts @@ -4,12 +4,36 @@ type ElectronProcessWindow = Window & { }; }; +export interface ElectronRemoteProcess { + argv: string[]; + platform: + | 'aix' + | 'android' + | 'darwin' + | 'freebsd' + | 'haiku' + | 'linux' + | 'openbsd' + | 'sunos' + | 'win32' + | 'cygwin' + | 'netbsd' + | string; +} + +export interface ElectronRemote { + app?: { + getVersion: () => string; + }; + process: ElectronRemoteProcess; +} + export abstract class DataService { get isElectron(): boolean { const currentWindow = window as ElectronProcessWindow; return Boolean(currentWindow.process?.type); } - get remote() { + get remote(): ElectronRemote | null { return null; } get ipcRenderer() { diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 490185458..92006c4d0 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -93,7 +93,7 @@ (contextMenuRequested)="onChannelContextMenu(channel, $event)" [selected]="activeChannelUrl() === channel?.url" [showFavoriteButton]="true" - [isFavorite]="favoriteIds().has(channel?.url)" + [isFavorite]="favoriteIds().has(channel?.url ?? '')" (favoriteToggled)="onFavoriteToggle(channel, $event)" /> diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index e93d8f2a3..855ebd5d3 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -74,7 +74,10 @@ }
} @else if (showSeasonEmptyState()) { -
+
@@ -109,7 +112,7 @@
@if (episodeInfo?.movie_image) { - @if (getEpisodePositionText(episode); as time) { -
+ @if ( + getEpisodePositionText(episode); + as time + ) { +
{{ time }}
} @@ -141,17 +149,30 @@ - } @else if (isEpisodeDownloading(episode)) { + } @else if ( + isEpisodeDownloading(episode) + ) { @@ -159,8 +180,16 @@ @@ -173,7 +202,8 @@ [matTooltip]=" (isEpisodeWatched(episode) ? 'XTREAM.MARK_UNWATCHED' - : 'XTREAM.MARK_WATCHED') | translate + : 'XTREAM.MARK_WATCHED' + ) | translate " > {{ @@ -186,7 +216,9 @@
@if (isEpisodeLaunching(episode)) { - + } @else { {{ isEpisodeActiveExternal(episode) @@ -202,16 +234,17 @@

- {{ episode.episode_num }}. {{ episode.title }} + {{ episode.episode_num }}. + {{ episode.title }}

@if (episodeInfo?.plot) {

- {{ episodeInfo.plot }} + {{ episodeInfo?.plot }}

} @if (episodeInfo?.duration) { - {{ episodeInfo.duration }} + {{ episodeInfo?.duration }} }
@@ -226,10 +259,18 @@
{{ episode.episode_num }} @@ -239,17 +280,26 @@

{{ episode.title }} @if (isEpisodeWatched(episode)) { - check_circle + check_circle }

@if (episodeInfo?.duration) { - - {{ episodeInfo.duration }} + + {{ episodeInfo?.duration }} } - @if (getEpisodePositionText(episode); as time) { - + @if ( + getEpisodePositionText(episode); + as time + ) { + {{ time }} } @@ -257,14 +307,18 @@
@if (episodeInfo?.plot) {

- {{ episodeInfo.plot }} + {{ episodeInfo?.plot }}

} @if (getEpisodeProgress(episode) > 0) { -
+
} @@ -273,12 +327,17 @@ @@ -302,7 +366,10 @@ mat-icon-button class="action-btn" disabled - [matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate" + [matTooltip]=" + 'DOWNLOADS.STATUS.DOWNLOADING' + | translate + " > downloading @@ -310,8 +377,12 @@ @@ -324,9 +395,12 @@ [matTooltip]=" (isEpisodeWatched(episode) ? 'XTREAM.MARK_UNWATCHED' - : 'XTREAM.MARK_WATCHED') | translate + : 'XTREAM.MARK_WATCHED' + ) | translate + " + [class.action-btn--success]=" + isEpisodeWatched(episode) " - [class.action-btn--success]="isEpisodeWatched(episode)" > {{ isEpisodeWatched(episode) diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index 22caea71d..f7c21358f 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -51,7 +51,7 @@ export class EmbeddedMpvSessionController { this.session.set(session); }); - if (window.electron?.getEmbeddedMpvSupport) { + if (typeof window.electron?.getEmbeddedMpvSupport === 'function') { void this.loadSupport(); } else { this.support.set({ diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/index.ts b/libs/ui/playback/src/lib/embedded-mpv-player/index.ts new file mode 100644 index 000000000..da8fbfccf --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/index.ts @@ -0,0 +1,3 @@ +export * from './embedded-mpv-overlay-visibility.service'; +export * from './embedded-mpv-player.component'; +export * from './embedded-mpv-session-controller'; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index cc3e2a5c1..e5cacbb7b 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -174,7 +174,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { audioTracks.addEventListener('addtrack', () => { debugVjsPlayer( '[AudioTrack] addtrack event fired, total tracks:', - this.player.audioTracks().length + audioTracks.length ); this.logAudioTracks(); this.setupAudioTrackMenu(); @@ -182,7 +182,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { audioTracks.addEventListener('removetrack', () => { debugVjsPlayer( '[AudioTrack] removetrack event fired, total tracks:', - this.player.audioTracks().length + audioTracks.length ); this.logAudioTracks(); this.setupAudioTrackMenu(); @@ -193,19 +193,19 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { } this.player.on('volumechange', () => { - const currentVolume = this.player.volume(); + const currentVolume = this.player.volume() ?? this.volume(); localStorage.setItem('volume', currentVolume.toString()); }); this.player.on('timeupdate', () => { this.timeUpdate.emit({ - currentTime: this.player.currentTime(), - duration: this.player.duration(), + currentTime: this.player.currentTime() ?? 0, + duration: this.player.duration() ?? 0, }); }); // Attach mpegts.js after Video.js is ready - if (isMpegTs) { + if (isMpegTs && source) { this.initMpegTs(source.src); } }) as unknown as VideoJsPlayer; @@ -311,7 +311,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { private initMpegTs(url: string): void { const videoEl = this.player .tech({ IWillNotUseThisInPlugins: true }) - ?.el(); + ?.el?.(); if (!videoEl) return; debugVjsPlayer('Using mpegts.js for TS stream:', url); @@ -473,7 +473,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { '[AudioTrack] Audio tracks count:', audioTracks?.length ?? 0 ); - for (let i = 0; i < (audioTracks?.length ?? 0); i++) { + if (!audioTracks) { + return; + } + + for (let i = 0; i < audioTracks.length; i++) { const t = audioTracks[i]; debugVjsPlayer( `[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"` @@ -529,9 +533,9 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { } let audioButton = - controlBar.getChild('audioTrackButton') ?? - controlBar.getChild('AudioTrackButton'); - if (!audioButton) { + controlBar.getChild?.('audioTrackButton') ?? + controlBar.getChild?.('AudioTrackButton'); + if (!audioButton && controlBar.addChild) { audioButton = controlBar.addChild('audioTrackButton', {}); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index bb70806f8..1082a6fb7 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -1,4 +1,4 @@ -@if (items()?.length > 0) { +@if ((items()?.length ?? 0) > 0) {