diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 8f9abc584..f479dc04f 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -64,6 +64,13 @@ The PWA continues to use `PwaService`; only the backend base URL is resolved at runtime. Electron routes remain owned by the Electron backend and preload bridge. +Renderer code that needs to branch by runtime should use +`RuntimeCapabilitiesService` from `@iptvnator/services` instead of adding new +direct `window.electron` or `DataService.getAppEnvironment()` checks. Keep +feature decisions expressed as capabilities such as `supportsEpg`, +`supportsSqlite`, `supportsDownloads`, or `supportsManagedExternalPlayers` so +PWA and Electron behavior stays auditable from one shared boundary. + ## Runtime Limitations The self-hosted build is the browser PWA, not the Electron desktop app. Keep diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index dc520bdbc..ec1996b4b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -31,6 +31,7 @@ import { import { DataService, PlaylistsService, + RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; import { @@ -255,9 +256,6 @@ describe('VideoPlayerComponent', () => { ), }; const dataServiceMock = { - get supportsEpg() { - return Boolean(window.electron); - }, sendIpcEvent: jest.fn(), }; @@ -336,6 +334,17 @@ describe('VideoPlayerComponent', () => { provide: DataService, useValue: dataServiceMock, }, + { + provide: RuntimeCapabilitiesService, + useValue: { + get supportsEpg() { + return Boolean(window.electron); + }, + get isElectron() { + return Boolean(window.electron); + }, + }, + }, { provide: PlaylistsService, useValue: playlistsServiceMock, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index d85f6811e..9e407777a 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -80,6 +80,7 @@ import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; import { DataService, PlaylistsService, + RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; import { @@ -133,6 +134,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly playlistsService = inject(PlaylistsService); private readonly playlistContext = inject(PlaylistContextFacade); private readonly router = inject(Router); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly storage = inject(StorageMap); private readonly store = inject(Store); @@ -250,8 +252,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { showCaptions: false, }; - readonly isDesktop = !!window['electron']; - readonly supportsEpg = this.dataService.supportsEpg; + readonly isDesktop = this.runtime.isElectron; + readonly supportsEpg = this.runtime.supportsEpg; readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute); /** EPG overlay reference */ 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 5b9bc55f0..22d9a2404 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 @@ -36,6 +36,7 @@ import { PlaylistsService, PortalStatus, PortalStatusService, + RuntimeCapabilitiesService, } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { firstValueFrom, startWith } from 'rxjs'; @@ -80,6 +81,7 @@ export class PlaylistSwitcherComponent { private readonly dialogService = inject(DialogService); private readonly databaseService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly snackBar = inject(MatSnackBar); private readonly store = inject(Store); private focusSearchTimeoutId: ReturnType | null = null; @@ -313,7 +315,7 @@ export class PlaylistSwitcherComponent { private async removePlaylistConfirmed( playlist: PlaylistMeta ): Promise { - const deleted = window.electron + const deleted = this.runtime.isElectron ? await this.deletePlaylistInElectron(playlist) : await this.deletePlaylistInBrowser(playlist); 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 3c791d6dd..f147a8ef5 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 @@ -36,6 +36,7 @@ import { PlaybackPositionService, PlaylistRefreshService, PlaylistsService, + RuntimeCapabilitiesService, SortBy, SortService, XtreamPendingRestoreService, @@ -83,6 +84,7 @@ export class RecentPlaylistsComponent { private readonly snackBar = inject(MatSnackBar); private readonly sortService = inject(SortService); private readonly store = inject(Store); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly translate = inject(TranslateService); private readonly playlistContext = inject(PlaylistContextFacade); private readonly playlistsService = inject(PlaylistsService); @@ -95,7 +97,7 @@ export class RecentPlaylistsComponent { readonly playlistClicked = output(); readonly addPlaylistClicked = output(); - readonly isElectron = !!window.electron; + readonly isElectron = this.runtime.isElectron; readonly allPlaylistsLoaded = this.store.selectSignal( selectPlaylistsLoadingFlag diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index cc31575c9..abab84386 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -5,7 +5,11 @@ import { XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access'; -import { DataService, PlaylistsService } from '@iptvnator/services'; +import { + DataService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; import { @@ -18,7 +22,7 @@ describe('StreamResolverService', () => { let playlistsService: { getPlaylistById: jest.Mock }; let xtreamApi: { getShortEpg: jest.Mock }; let xtreamUrl: { constructLiveUrl: jest.Mock }; - let dataService: { readonly supportsEpg: boolean; sendIpcEvent: jest.Mock }; + let dataService: { sendIpcEvent: jest.Mock }; let stalkerSession: { makeAuthenticatedRequest: jest.Mock }; beforeEach(() => { @@ -32,9 +36,6 @@ describe('StreamResolverService', () => { constructLiveUrl: jest.fn(), }; dataService = { - get supportsEpg() { - return Boolean(window.electron); - }, sendIpcEvent: jest.fn(), }; stalkerSession = { @@ -53,6 +54,14 @@ describe('StreamResolverService', () => { { provide: XtreamApiService, useValue: xtreamApi }, { provide: XtreamUrlService, useValue: xtreamUrl }, { provide: DataService, useValue: dataService }, + { + provide: RuntimeCapabilitiesService, + useValue: { + get supportsEpg() { + return Boolean(window.electron); + }, + }, + }, { provide: StalkerSessionService, useValue: stalkerSession }, ], }); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 1a7352f15..53a9c5f8a 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -1,6 +1,10 @@ import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { DataService, PlaylistsService } from '@iptvnator/services'; +import { + DataService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { Channel, EpgItem, @@ -59,6 +63,7 @@ export class StreamResolverService { private readonly xtreamApi = inject(XtreamApiService); private readonly xtreamUrl = inject(XtreamUrlService); private readonly dataService = inject(DataService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly stalkerSession = inject(StalkerSessionService); private readonly m3uEpgTimeoutMs = 3000; private readonly portalEpgTimeoutMs = 3000; @@ -68,7 +73,7 @@ export class StreamResolverService { private readonly xtreamEpgFailureCooldownMs = 60 * 1000; private get supportsEpg(): boolean { - return this.dataService.supportsEpg; + return this.runtime.supportsEpg; } private async getElectronPlaylist( diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 7f2c42bfe..95b651e1c 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -23,7 +23,10 @@ import { shiftEpgDateKey, } from '@iptvnator/ui/epg'; import { ResizableDirective } from '@iptvnator/ui/components'; -import { DataService, SettingsStore } from '@iptvnator/services'; +import { + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { Channel, EpgItem, @@ -193,7 +196,7 @@ describe('UnifiedLiveTabComponent', () => { { provide: StreamResolverService, useValue: streamResolver }, { provide: UnifiedRecentDataService, useValue: recentData }, { - provide: DataService, + provide: RuntimeCapabilitiesService, useValue: { get supportsEpg() { return Boolean(window.electron); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 4864f6434..5605f1292 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -45,7 +45,7 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { ResizableDirective } from '@iptvnator/ui/components'; -import { DataService, SettingsStore } from '@iptvnator/services'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; import { EpgViewComponent, @@ -92,13 +92,13 @@ export class UnifiedLiveTabComponent { private readonly streamResolver = inject(StreamResolverService); private readonly recentData = inject(UnifiedRecentDataService); - private readonly dataService = inject(DataService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly destroyRef = inject(DestroyRef); readonly player = this.settingsStore.player; - readonly supportsEpg = this.dataService.supportsEpg; + readonly supportsEpg = this.runtime.supportsEpg; readonly isEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts index 088be301b..499f901d8 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { signalStore, withState } from '@ngrx/signals'; -import { DataService } from '@iptvnator/services'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; import { EpgItem, Playlist } from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerEpg } from './with-stalker-epg.feature'; @@ -36,18 +36,16 @@ const TestStalkerEpgStore = signalStore( describe('withStalkerEpg', () => { let store: InstanceType; let dataService: { - getAppEnvironment: jest.Mock; - supportsEpg: boolean; sendIpcEvent: jest.Mock, unknown[]>; }; + let runtimeSupportsEpg: boolean; let stalkerSessionService: { makeAuthenticatedRequest: jest.Mock, unknown[]>; }; beforeEach(() => { + runtimeSupportsEpg = true; dataService = { - getAppEnvironment: jest.fn(() => 'electron'), - supportsEpg: true, sendIpcEvent: jest.fn(), }; stalkerSessionService = { @@ -58,6 +56,14 @@ describe('withStalkerEpg', () => { providers: [ TestStalkerEpgStore, { provide: DataService, useValue: dataService }, + { + provide: RuntimeCapabilitiesService, + useValue: { + get supportsEpg() { + return runtimeSupportsEpg; + }, + }, + }, { provide: StalkerSessionService, useValue: stalkerSessionService, @@ -98,8 +104,7 @@ describe('withStalkerEpg', () => { }); it('does not request short EPG in browser/PWA mode', async () => { - dataService.getAppEnvironment.mockReturnValue('pwa'); - dataService.supportsEpg = false; + runtimeSupportsEpg = false; const result = await store.fetchChannelEpg('10001'); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts index 2051cbb4a..ce0748177 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts @@ -7,7 +7,7 @@ import { withState, } from '@ngrx/signals'; import { createLogger } from '@iptvnator/portal/shared/util'; -import { DataService } from '@iptvnator/services'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; import { EpgItem, EpgProgram, @@ -98,7 +98,8 @@ export function withStalkerEpg() { ( store, dataService = inject(DataService), - stalkerSession = inject(StalkerSessionService) + stalkerSession = inject(StalkerSessionService), + runtime = inject(RuntimeCapabilitiesService) ) => { const storeContext = store as typeof store & StalkerEpgFeatureStoreContract; @@ -106,6 +107,8 @@ export function withStalkerEpg() { dataService, stalkerSession, }; + const supportsEpg = (): boolean => runtime.supportsEpg; + const requestEpg = async ( playlist: NonNullable< ReturnType< @@ -125,7 +128,7 @@ export function withStalkerEpg() { channelId: number | string, size: number ): Promise => { - if (!dataService.supportsEpg) { + if (!supportsEpg()) { return []; } @@ -182,7 +185,7 @@ export function withStalkerEpg() { } const playlistId = String(playlist._id); - if (!dataService.supportsEpg) { + if (!supportsEpg()) { patchState(store, { bulkItvEpgByChannel: {}, bulkItvEpgLoaded: true, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 752c5b15c..eea6577ed 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -17,8 +17,8 @@ import { ChannelListItemComponent } from '@iptvnator/ui/components'; import { MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; import { - DataService, PlaylistsService, + RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; @@ -313,11 +313,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { providers: [ { provide: StalkerStore, useValue: stalkerStore }, { - provide: DataService, + provide: RuntimeCapabilitiesService, useValue: { get supportsEpg() { return Boolean(window.electron); }, + get isElectron() { + return Boolean(window.electron); + }, }, }, { provide: PlaylistsService, useValue: playlistService }, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 3066f4881..ed454b032 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -26,8 +26,8 @@ import { ResizableDirective, } from '@iptvnator/ui/components'; import { - DataService, PlaylistsService, + RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; import { @@ -100,8 +100,8 @@ type StalkerPlayableChannel = StalkerPortalItem & { }) export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly stalkerStore = inject(StalkerStore); - private readonly dataService = inject(DataService); private readonly playlistService = inject(PlaylistsService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly snackBar = inject(MatSnackBar); @@ -149,8 +149,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly selectedChannelId = this.stalkerStore.selectedItvId; protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; - readonly isElectron = Boolean(window.electron); - readonly supportsEpg = this.dataService.supportsEpg; + readonly isElectron = this.runtime.isElectron; + readonly supportsEpg = this.runtime.supportsEpg; readonly openStreamOnDoubleClick = computed(() => this.settingsStore.openStreamOnDoubleClick() ); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts index ff01727ba..9abd9febb 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts @@ -1,5 +1,8 @@ import { inject, Provider } from '@angular/core'; -import { PLAYLIST_DELETE_CLEANUP } from '@iptvnator/services'; +import { + PLAYLIST_DELETE_CLEANUP, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { ElectronXtreamDataSource } from './electron-xtream-data-source'; import { PwaXtreamDataSource } from './pwa-xtream-data-source'; import { @@ -18,12 +21,12 @@ export { PwaXtreamDataSource } from './pwa-xtream-data-source'; * - PWA: Uses API-only with in-memory caching and localStorage for user data */ export function xtreamDataSourceFactory(): IXtreamDataSource { - // Check if we're in Electron environment - if (typeof window !== 'undefined' && window.electron) { + const runtime = inject(RuntimeCapabilitiesService); + + if (runtime.isElectron) { return inject(ElectronXtreamDataSource); } - // Default to PWA implementation return inject(PwaXtreamDataSource); } @@ -44,9 +47,10 @@ export function provideXtreamDataSource(): Provider[] { multi: true, useFactory: () => { const dataSource = inject(XTREAM_DATA_SOURCE); + const runtime = inject(RuntimeCapabilitiesService); return (playlistId: string) => { - if (typeof window !== 'undefined' && window.electron) { + if (runtime.isElectron) { // Electron playlist deletion must use DatabaseService so // SQLite content and sidecars are cleaned together; do // not invoke this cleanup path through PlaylistsService. diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts index d178dce39..f01456a67 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.spec.ts @@ -1,6 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { signalStore, withState } from '@ngrx/signals'; -import { DataService, SettingsStore } from '@iptvnator/services'; +import { + DataService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { EpgItem } from '@iptvnator/shared/interfaces'; import { XtreamApiService } from '../../services/xtream-api.service'; import { XtreamXmltvFallbackService } from '../../services/xtream-xmltv-fallback.service'; @@ -90,6 +94,14 @@ function configureStore(setup: TestStoreSetup) { (setup.isElectron ?? true), }, }, + { + provide: RuntimeCapabilitiesService, + useValue: { + get supportsEpg() { + return (setup.appEnvironment ?? 'electron') !== 'pwa'; + }, + }, + }, { provide: XtreamApiService, useValue: xtreamApiService }, { provide: XtreamXmltvFallbackService, diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 7723705ee..4161a613e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -7,7 +7,11 @@ import { withState, } from '@ngrx/signals'; import { EpgItem } from '@iptvnator/shared/interfaces'; -import { DataService, SettingsStore } from '@iptvnator/services'; +import { + DataService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { XtreamApiService, XtreamCredentials, @@ -82,8 +86,11 @@ export function withEpg() { const apiService = inject(XtreamApiService); const dataService = inject(DataService); const fallbackService = inject(XtreamXmltvFallbackService); + const runtime = inject(RuntimeCapabilitiesService); const settingsStore = inject(SettingsStore); + const supportsEpg = (): boolean => runtime.supportsEpg; + /** * Helper to get credentials from parent store */ @@ -126,7 +133,7 @@ export function withEpg() { * sets `preferUploadedEpgOverXtream`. */ async loadEpg(): Promise { - if (!dataService.supportsEpg) { + if (!supportsEpg()) { patchState(store, { epgItems: [], isLoadingEpg: false, @@ -182,7 +189,7 @@ export function withEpg() { streamId: number, epgChannelId?: string | null ): Promise { - if (!dataService.supportsEpg) { + if (!supportsEpg()) { return []; } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index a26756262..ded053d9f 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -28,7 +28,10 @@ import { import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { LiveStreamLayoutComponent } from './live-stream-layout.component'; -import { DataService, SettingsStore } from '@iptvnator/services'; +import { + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; @@ -225,11 +228,14 @@ describe('LiveStreamLayoutComponent', () => { { provide: FavoritesService, useValue: favoritesService }, { provide: XtreamUrlService, useValue: xtreamUrlService }, { - provide: DataService, + provide: RuntimeCapabilitiesService, useValue: { get supportsEpg() { return Boolean(window.electron); }, + get isElectron() { + return Boolean(window.electron); + }, }, }, { provide: SettingsStore, useValue: settingsStore }, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 016b146fb..91ac35165 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -63,7 +63,7 @@ import { } from '@iptvnator/shared/interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; -import { DataService, SettingsStore } from '@iptvnator/services'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; @@ -104,7 +104,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); private readonly xtreamUrlService = inject(XtreamUrlService); - private readonly dataService = inject(DataService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly liveSidebarStateService = inject( @@ -120,8 +120,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly isLoadingEpg = this.xtreamStore.isLoadingEpg; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; readonly liveChannelSortMode = signal('server'); - readonly isElectron = Boolean(window.electron); - readonly supportsEpg = this.dataService.supportsEpg; + readonly isElectron = this.runtime.isElectron; + readonly supportsEpg = this.runtime.supportsEpg; readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( this.route, diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 7d8682835..8c3dd8f2a 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -10,7 +10,10 @@ import { FavoritesService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; -import { DataService, SettingsStore } from '@iptvnator/services'; +import { + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { PortalChannelsListComponent } from './portal-channels-list.component'; function buildEpgItem(params: { @@ -129,7 +132,7 @@ describe('PortalChannelsListComponent', () => { useValue: epgQueueService, }, { - provide: DataService, + provide: RuntimeCapabilitiesService, useValue: { get supportsEpg() { return Boolean(window.electron); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index f970f77db..2d627ed02 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -38,7 +38,7 @@ import { EpgQueueService } from '@iptvnator/portal/xtream/data-access'; import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access'; import { FavoritesService } from '@iptvnator/portal/xtream/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { DataService } from '@iptvnator/services'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; export interface XtreamChannelListItem { readonly category_id?: string | number; @@ -78,11 +78,11 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { readonly searchTermInput = input(''); readonly xtreamStore = inject(XtreamStore); - private readonly dataService = inject(DataService); private readonly favoritesService = inject(FavoritesService); private readonly epgQueueService = inject(EpgQueueService); private readonly route = inject(ActivatedRoute); - readonly supportsEpg = this.dataService.supportsEpg; + private readonly runtime = inject(RuntimeCapabilitiesService); + readonly supportsEpg = this.runtime.supportsEpg; readonly isSelectedTypeContentLoading = this.xtreamStore.selectedTypeContentLoading; readonly channels = computed(() => { diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 17b064575..16fd01c4d 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/playlist-backup.service'; export * from './lib/playlist-refresh.service'; export * from './lib/playlists.service'; export * from './lib/portal-status.service'; +export * from './lib/runtime-capabilities.service'; export * from './lib/settings-store.service'; export * from './lib/sort.service'; export * from './lib/xtream-pending-restore.service'; diff --git a/libs/services/src/lib/data.service.ts b/libs/services/src/lib/data.service.ts index d2b75065c..a01e6f383 100644 --- a/libs/services/src/lib/data.service.ts +++ b/libs/services/src/lib/data.service.ts @@ -33,9 +33,6 @@ export abstract class DataService { const currentWindow = window as ElectronProcessWindow; return Boolean(currentWindow.process?.type); } - get supportsEpg(): boolean { - return typeof window !== 'undefined' && Boolean(window.electron); - } get remote(): ElectronRemote | null { return null; } diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index c6f03aedb..5d641014b 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -36,6 +36,20 @@ describe('PlaylistsService', () => { translateService: { instant: jest.fn((key: string) => key), }, + runtime: { + get supportsSqlite() { + const electron = testWindow.electron as + | Record + | undefined; + return ( + !!electron && + typeof electron['dbGetAppPlaylists'] === 'function' && + typeof electron['dbUpsertAppPlaylist'] === 'function' && + typeof electron['dbGetAppState'] === 'function' && + typeof electron['dbSetAppState'] === 'function' + ); + }, + }, electronMigrationPromise: null, indexedDbMigrationPromise: null, playlistDeleteCleanups: [], diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index d9af054c4..4aba72a24 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -31,6 +31,7 @@ import { normalizeStalkerDate, } from '@iptvnator/shared/interfaces'; import { PLAYLIST_DELETE_CLEANUP } from './playlist-delete-cleanup.token'; +import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG = @@ -82,6 +83,7 @@ export class PlaylistsService { private readonly dbService = inject(NgxIndexedDBService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly playlistDeleteCleanups = inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? []; private electronMigrationPromise: Promise | null = null; @@ -96,15 +98,7 @@ export class PlaylistsService { } private get isElectronStorageAvailable(): boolean { - const electron = this.electronApi; - - return ( - !!electron && - typeof electron.dbGetAppPlaylists === 'function' && - typeof electron.dbUpsertAppPlaylist === 'function' && - typeof electron.dbGetAppState === 'function' && - typeof electron.dbSetAppState === 'function' - ); + return this.runtime.supportsSqlite; } private runOnSqlite(operation: () => Promise) { diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts new file mode 100644 index 000000000..05c9e6951 --- /dev/null +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -0,0 +1,81 @@ +import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; + +describe('RuntimeCapabilitiesService', () => { + const testWindow = window as unknown as { + electron?: Record; + }; + const originalElectron = testWindow.electron; + + afterEach(() => { + testWindow.electron = originalElectron; + }); + + it('reports browser PWA capabilities when the Electron bridge is absent', () => { + testWindow.electron = undefined; + + const service = new RuntimeCapabilitiesService(); + + expect(service.environment).toBe('pwa'); + expect(service.isPwa).toBe(true); + expect(service.isElectron).toBe(false); + expect(service.supportsEpg).toBe(false); + expect(service.supportsSqlite).toBe(false); + expect(service.supportsDownloads).toBe(false); + expect(service.supportsManagedExternalPlayers).toBe(false); + expect(service.supportsEmbeddedMpv).toBe(false); + expect(service.supportsRemoteControl).toBe(false); + }); + + it('reports Electron capabilities from the available preload bridge methods', () => { + testWindow.electron = { + dbGetAppPlaylists: jest.fn(), + dbUpsertAppPlaylist: jest.fn(), + dbGetAppState: jest.fn(), + dbSetAppState: jest.fn(), + downloadsGetList: jest.fn(), + prepareEmbeddedMpv: jest.fn(), + updateRemoteControlStatus: jest.fn(), + onChannelChange: jest.fn(), + onRemoteControlCommand: jest.fn(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.environment).toBe('electron'); + expect(service.isElectron).toBe(true); + expect(service.isPwa).toBe(false); + expect(service.supportsEpg).toBe(true); + expect(service.supportsSqlite).toBe(true); + expect(service.supportsDownloads).toBe(true); + expect(service.supportsManagedExternalPlayers).toBe(true); + expect(service.supportsEmbeddedMpv).toBe(true); + expect(service.supportsRemoteControl).toBe(true); + }); + + it('keeps feature-specific capabilities false when an Electron bridge is partial', () => { + testWindow.electron = { + updateRemoteControlStatus: jest.fn(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.environment).toBe('electron'); + expect(service.supportsEpg).toBe(true); + expect(service.supportsSqlite).toBe(false); + expect(service.supportsDownloads).toBe(false); + expect(service.supportsEmbeddedMpv).toBe(false); + expect(service.supportsRemoteControl).toBe(false); + }); + + it('reads the bridge dynamically so tests and late preload setup stay accurate', () => { + testWindow.electron = undefined; + const service = new RuntimeCapabilitiesService(); + + expect(service.isPwa).toBe(true); + + testWindow.electron = {}; + + expect(service.isElectron).toBe(true); + expect(service.environment).toBe('electron'); + }); +}); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts new file mode 100644 index 000000000..f5a18580b --- /dev/null +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; + +export type RuntimeEnvironment = 'electron' | 'pwa'; + +type RuntimeElectronBridge = Record; + +type RuntimeWindow = Window & { + electron?: RuntimeElectronBridge; +}; + +@Injectable({ providedIn: 'root' }) +export class RuntimeCapabilitiesService { + get environment(): RuntimeEnvironment { + return this.isElectron ? 'electron' : 'pwa'; + } + + get isElectron(): boolean { + return !!this.electronBridge; + } + + get isPwa(): boolean { + return !this.isElectron; + } + + get supportsEpg(): boolean { + return this.isElectron; + } + + get supportsSqlite(): boolean { + return ( + this.hasElectronMethod('dbGetAppPlaylists') && + this.hasElectronMethod('dbUpsertAppPlaylist') && + this.hasElectronMethod('dbGetAppState') && + this.hasElectronMethod('dbSetAppState') + ); + } + + get supportsDownloads(): boolean { + return this.hasElectronMethod('downloadsGetList'); + } + + get supportsManagedExternalPlayers(): boolean { + return this.isElectron; + } + + get supportsEmbeddedMpv(): boolean { + return this.hasElectronMethod('prepareEmbeddedMpv'); + } + + get supportsRemoteControl(): boolean { + return ( + this.hasElectronMethod('updateRemoteControlStatus') && + this.hasElectronMethod('onChannelChange') && + this.hasElectronMethod('onRemoteControlCommand') + ); + } + + private hasElectronMethod(methodName: string): boolean { + return typeof this.electronBridge?.[methodName] === 'function'; + } + + private get electronBridge(): RuntimeElectronBridge | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return (window as RuntimeWindow).electron; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index dee105b3f..d2bb748ad 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -30,7 +30,11 @@ import { } from '@iptvnator/workspace/shell/util'; import { DialogService } from '@iptvnator/ui/components'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { DashboardDataService, DashboardFavoriteItem, @@ -428,11 +432,12 @@ export class WorkspaceDashboardRailsComponent { private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS); private readonly epgService = inject(EpgService); private readonly playlistsService = inject(PlaylistsService); + private readonly runtime = inject(RuntimeCapabilitiesService); readonly hasPlaylists = computed(() => this.data.playlists().length > 0); readonly ready = this.data.dashboardReady; readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount; - readonly isElectron = !!window.electron; + readonly isElectron = this.runtime.isElectron; readonly skeletonSlots = SKELETON_CARDS_PER_RAIL; readonly skeletonRails = SKELETON_RAILS; @@ -776,7 +781,7 @@ export class WorkspaceDashboardRailsComponent { } private async removePlaylist(playlist: PlaylistMeta): Promise { - const deleted = window.electron + const deleted = this.runtime.isElectron ? await this.deletePlaylistInElectron(playlist) : await this.deletePlaylistInBrowser(playlist);