mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
refactor(runtime): centralize platform capabilities
This commit is contained in:
1 parent
39c1c10492
commit
c571c57c5b
27 files changed
+314
-71
No files matched your search
@@ -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
|
||||
|
||||
+12
-3
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
@@ -313,7 +315,7 @@ export class PlaylistSwitcherComponent {
|
||||
private async removePlaylistConfirmed(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<void> {
|
||||
const deleted = window.electron
|
||||
const deleted = this.runtime.isElectron
|
||||
? await this.deletePlaylistInElectron(playlist)
|
||||
: await this.deletePlaylistInBrowser(playlist);
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
readonly addPlaylistClicked = output<PlaylistType | undefined>();
|
||||
|
||||
readonly isElectron = !!window.electron;
|
||||
readonly isElectron = this.runtime.isElectron;
|
||||
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+3
-3
@@ -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()
|
||||
);
|
||||
|
||||
+12
-7
@@ -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<typeof TestStalkerEpgStore>;
|
||||
let dataService: {
|
||||
getAppEnvironment: jest.Mock<string, []>;
|
||||
supportsEpg: boolean;
|
||||
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
|
||||
};
|
||||
let runtimeSupportsEpg: boolean;
|
||||
let stalkerSessionService: {
|
||||
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, 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');
|
||||
|
||||
|
||||
@@ -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<EpgItem[]> => {
|
||||
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,
|
||||
|
||||
+5
-2
@@ -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 },
|
||||
|
||||
+4
-4
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EpgItem[]> {
|
||||
if (!dataService.supportsEpg) {
|
||||
if (!supportsEpg()) {
|
||||
patchState(store, {
|
||||
epgItems: [],
|
||||
isLoadingEpg: false,
|
||||
@@ -182,7 +189,7 @@ export function withEpg() {
|
||||
streamId: number,
|
||||
epgChannelId?: string | null
|
||||
): Promise<EpgItem[]> {
|
||||
if (!dataService.supportsEpg) {
|
||||
if (!supportsEpg()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -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 },
|
||||
|
||||
+4
-4
@@ -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<PortalChannelSortMode>('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,
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+3
-3
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,20 @@ describe('PlaylistsService', () => {
|
||||
translateService: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
runtime: {
|
||||
get supportsSqlite() {
|
||||
const electron = testWindow.electron as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return (
|
||||
!!electron &&
|
||||
typeof electron['dbGetAppPlaylists'] === 'function' &&
|
||||
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
|
||||
typeof electron['dbGetAppState'] === 'function' &&
|
||||
typeof electron['dbSetAppState'] === 'function'
|
||||
);
|
||||
},
|
||||
},
|
||||
electronMigrationPromise: null,
|
||||
indexedDbMigrationPromise: null,
|
||||
playlistDeleteCleanups: [],
|
||||
|
||||
@@ -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<void> | 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<T>(operation: () => Promise<T>) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
describe('RuntimeCapabilitiesService', () => {
|
||||
const testWindow = window as unknown as {
|
||||
electron?: Record<string, unknown>;
|
||||
};
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export type RuntimeEnvironment = 'electron' | 'pwa';
|
||||
|
||||
type RuntimeElectronBridge = Record<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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<void> {
|
||||
const deleted = window.electron
|
||||
const deleted = this.runtime.isElectron
|
||||
? await this.deletePlaylistInElectron(playlist)
|
||||
: await this.deletePlaylistInBrowser(playlist);
|
||||
|
||||
|
||||
Reference in new issue
Block a user