diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 8f1a48bd3..1e5821d0a 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -10,6 +10,7 @@ import { DbOperationEvent, PlaybackPositionService, PlaylistRefreshService, + RuntimeCapabilitiesService, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; @@ -75,6 +76,9 @@ describe('PlaylistRefreshActionService', () => { let playbackPositionService: { getAllPlaybackPositions: jest.Mock; }; + let runtime: { + isElectron: boolean; + }; let routeProvider: ReturnType< typeof signal<'playlists' | 'xtreams' | null> >; @@ -126,6 +130,9 @@ describe('PlaylistRefreshActionService', () => { playbackPositionService = { getAllPlaybackPositions: jest.fn().mockResolvedValue([]), }; + runtime = { + isElectron: true, + }; routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams'); resolvedPlaylistId = signal(null); @@ -166,6 +173,10 @@ describe('PlaylistRefreshActionService', () => { provide: PlaybackPositionService, useValue: playbackPositionService, }, + { + provide: RuntimeCapabilitiesService, + useValue: runtime, + }, { provide: PlaylistContextFacade, useValue: { @@ -185,7 +196,7 @@ describe('PlaylistRefreshActionService', () => { }); it('treats file-backed M3U playlists as refreshable in Electron', () => { - window.electron = { platform: 'darwin' } as typeof window.electron; + runtime.isElectron = true; expect( service.canRefresh( @@ -200,7 +211,7 @@ describe('PlaylistRefreshActionService', () => { }); it('does not expose filesystem refresh outside Electron', () => { - window.electron = undefined as unknown as typeof window.electron; + runtime.isElectron = false; expect( service.canRefresh( diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index da200ff02..1d9e113fe 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -10,6 +10,7 @@ import { isDbAbortError, PlaybackPositionService, PlaylistRefreshService, + RuntimeCapabilitiesService, XtreamPendingRestoreService, } from '@iptvnator/services'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; @@ -34,6 +35,7 @@ export class PlaylistRefreshActionService { private readonly databaseService = inject(DatabaseService); private readonly playbackPositionService = inject(PlaybackPositionService); private readonly playlistRefreshService = inject(PlaylistRefreshService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly playlistContext = inject(PlaylistContextFacade); private readonly pendingRestoreService = inject( XtreamPendingRestoreService @@ -46,7 +48,7 @@ export class PlaylistRefreshActionService { readonly refreshPreparation = this.refreshPreparationState.asReadonly(); canRefresh(playlist: PlaylistMeta | null): boolean { - if (!playlist || !window.electron) { + if (!playlist || !this.runtime.isElectron) { return false; } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts new file mode 100644 index 000000000..b98a5df22 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -0,0 +1,150 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { PlaylistInfoComponent } from './playlist-info.component'; + +describe('PlaylistInfoComponent', () => { + let component: PlaylistInfoComponent; + let fixture: ComponentFixture; + let playlistsService: { + getRawPlaylistById: jest.Mock; + }; + let runtime: { + isElectron: boolean; + supportsDesktopFileSave: boolean; + }; + let snackBar: { + open: jest.Mock; + }; + const originalElectron = window.electron; + + const playlist = { + id: 'playlist-1', + _id: 'playlist-1', + title: 'My Playlist', + count: 1, + importDate: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + url: 'https://example.com/playlist.m3u', + } as Playlist & { id: string }; + + beforeEach(async () => { + playlistsService = { + getRawPlaylistById: jest.fn(() => of('#EXTM3U\n')), + }; + runtime = { + isElectron: false, + supportsDesktopFileSave: false, + }; + snackBar = { + open: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [PlaylistInfoComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: playlist, + }, + { + provide: PlaylistsService, + useValue: playlistsService, + }, + { + provide: DatabaseService, + useValue: { + updateXtreamPlaylistDetails: jest.fn(), + }, + }, + { + provide: Store, + useValue: { + dispatch: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: snackBar, + }, + { + provide: TranslateService, + useValue: { + instant: jest.fn((key: string) => key), + }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: runtime, + }, + ], + }).compileComponents(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + window.electron = originalElectron; + }); + + function createComponent(): void { + fixture = TestBed.createComponent(PlaylistInfoComponent); + component = fixture.componentInstance; + } + + it('uses the Electron save dialog when desktop file saving is available', async () => { + runtime.isElectron = true; + runtime.supportsDesktopFileSave = true; + window.electron = { + saveFileDialog: jest.fn().mockResolvedValue('/tmp/export.m3u8'), + writeFile: jest.fn().mockResolvedValue({ success: true }), + } as typeof window.electron; + createComponent(); + + await component.exportPlaylist(); + + expect(window.electron.saveFileDialog).toHaveBeenCalledWith( + 'My Playlist.m3u8', + [{ name: 'Playlist', extensions: ['m3u8', 'm3u'] }] + ); + expect(window.electron.writeFile).toHaveBeenCalledWith( + '/tmp/export.m3u8', + '#EXTM3U\n' + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_EXPORT_SUCCESS', + 'CLOSE', + { duration: 3000 } + ); + }); + + it('uses file-save capability for desktop-only playlist details UI', () => { + runtime.isElectron = true; + runtime.supportsDesktopFileSave = false; + createComponent(); + + expect(component.isDesktop).toBe(false); + }); + + it('falls back to browser download when desktop file saving is unavailable', async () => { + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(); + createComponent(); + + await component.exportPlaylist(); + + expect(playlistsService.getRawPlaylistById).toHaveBeenCalledWith( + 'playlist-1' + ); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 1aa267ca6..9b7f1b6a2 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -19,9 +19,18 @@ import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { firstValueFrom } from 'rxjs'; -import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; +type DesktopFileSaveBridge = Pick< + typeof window.electron, + 'saveFileDialog' | 'writeFile' +>; + @Component({ selector: 'app-playlist-info', templateUrl: './playlist-info.component.html', @@ -80,9 +89,12 @@ export class PlaylistInfoComponent { private databaseService = inject(DatabaseService); private snackBar = inject(MatSnackBar); private translate = inject(TranslateService); + private runtime = inject(RuntimeCapabilitiesService); public playlistData = inject(MAT_DIALOG_DATA); - readonly isDesktop = !!window.electron; + get isDesktop(): boolean { + return this.runtime.supportsDesktopFileSave; + } /** Playlist object */ playlist: Playlist & { id: string }; @@ -202,9 +214,12 @@ export class PlaylistInfoComponent { this.playlistsService.getRawPlaylistById(this.playlist._id) ); - if (this.isDesktop) { + if (this.runtime.supportsDesktopFileSave) { + const desktopFileBridge = + window.electron as DesktopFileSaveBridge; + try { - const savePath = await window.electron.saveFileDialog( + const savePath = await desktopFileBridge.saveFileDialog( `${this.playlist.title || 'exported'}.m3u8`, [ { @@ -215,7 +230,10 @@ export class PlaylistInfoComponent { ); if (savePath) { - await window.electron.writeFile(savePath, playlistAsString); + await desktopFileBridge.writeFile( + savePath, + playlistAsString + ); this.snackBar.open( this.translate.instant( 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_EXPORT_SUCCESS' @@ -224,6 +242,8 @@ export class PlaylistInfoComponent { { duration: 3000 } ); } + + return; } catch (error) { console.error('Failed to export playlist:', error); this.snackBar.open( @@ -235,23 +255,28 @@ export class PlaylistInfoComponent { duration: 3000, } ); + return; } - } else { - const element = document.createElement('a'); - element.setAttribute( - 'href', - 'data:text/plain;charset=utf-8,' + - encodeURIComponent(playlistAsString) - ); - element.setAttribute( - 'download', - this.playlist.title || 'exported.m3u' - ); - element.style.display = 'none'; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); } + + this.downloadPlaylistFile(playlistAsString); + } + + private downloadPlaylistFile(playlistAsString: string): void { + const element = document.createElement('a'); + element.setAttribute( + 'href', + 'data:text/plain;charset=utf-8,' + + encodeURIComponent(playlistAsString) + ); + element.setAttribute( + 'download', + this.playlist.title || 'exported.m3u' + ); + element.style.display = 'none'; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); } /** diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts index eed3a45b3..ef014dd10 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts @@ -4,14 +4,24 @@ import { MatListModule } from '@angular/material/list'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { MockModule } from 'ng-mocks'; -import { PortalStatusService } from '@iptvnator/services'; +import { + PortalStatusService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { PlaylistItemComponent } from './playlist-item.component'; describe('PlaylistItemComponent', () => { let component: PlaylistItemComponent; let fixture: ComponentFixture; + let runtime: { + isElectron: boolean; + }; beforeEach(waitForAsync(() => { + runtime = { + isElectron: true, + }; + TestBed.configureTestingModule({ imports: [ PlaylistItemComponent, @@ -31,6 +41,10 @@ describe('PlaylistItemComponent', () => { getStatusIcon: jest.fn(() => 'check_circle'), }, }, + { + provide: RuntimeCapabilitiesService, + useValue: runtime, + }, ], }).compileComponents(); })); @@ -94,21 +108,70 @@ describe('PlaylistItemComponent', () => { expect(nativeElement.querySelector('.refresh-btn')).not.toBeNull(); }); + it('renders the Xtream refresh action only when Electron capabilities are available', () => { + fixture.destroy(); + runtime.isElectron = true; + fixture = TestBed.createComponent(PlaylistItemComponent); + component = fixture.componentInstance; + component.item = { + title: 'Xtream Source', + _id: 'xtream-source', + count: 10, + importDate: Date.now().toString(), + autoRefresh: false, + serverUrl: 'https://example.com', + username: 'demo', + password: 'secret', + }; + fixture.detectChanges(); + + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.refresh-btn' + ) + ).not.toBeNull(); + + fixture.destroy(); + runtime.isElectron = false; + fixture = TestBed.createComponent(PlaylistItemComponent); + component = fixture.componentInstance; + component.item = { + title: 'Xtream Source', + _id: 'xtream-source', + count: 10, + importDate: Date.now().toString(), + autoRefresh: false, + serverUrl: 'https://example.com', + username: 'demo', + password: 'secret', + }; + fixture.detectChanges(); + + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.refresh-btn' + ) + ).toBeNull(); + }); + it('renders cancel and progress UI for long-running playlist actions', () => { fixture.componentRef.setInput('isDeleting', true); - fixture.componentRef.setInput('busyMessage', 'Removing cached content...'); + fixture.componentRef.setInput( + 'busyMessage', + 'Removing cached content...' + ); fixture.componentRef.setInput('busyProgress', 42); fixture.componentRef.setInput('canCancelBusyAction', true); fixture.detectChanges(); const nativeElement = fixture.nativeElement as HTMLElement; - expect(nativeElement.querySelector('.busy-state__message')?.textContent).toContain( - 'Removing cached content...' - ); - expect(nativeElement.querySelector('.busy-state__value')?.textContent).toContain( - '42%' - ); + expect( + nativeElement.querySelector('.busy-state__message')?.textContent + ).toContain('Removing cached content...'); + expect( + nativeElement.querySelector('.busy-state__value')?.textContent + ).toContain('42%'); expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull(); }); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index 453d6d127..dd5513b2d 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -18,7 +18,11 @@ import { MatTooltip } from '@angular/material/tooltip'; import { normalizeDateLocale } from '@iptvnator/pipes'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; -import { PortalStatus, PortalStatusService } from '@iptvnator/services'; +import { + PortalStatus, + PortalStatusService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; @Component({ @@ -56,13 +60,14 @@ export class PlaylistItemComponent implements OnInit { portalStatus: PortalStatus = 'unavailable'; private readonly portalStatusService = inject(PortalStatusService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly translate = inject(TranslateService); private readonly languageTick = toSignal( this.translate.onLangChange.pipe(startWith(null)), { initialValue: null } ); - readonly isElectron = !!window.electron; + readonly isElectron = this.runtime.isElectron; readonly currentLocale = computed(() => { this.languageTick(); return normalizeDateLocale( diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index 08a23ea86..577b3eda6 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -20,6 +20,8 @@ import { DbOperationEvent, PlaybackPositionService, PlaylistDeleteActionService, + PlaylistRefreshService, + RuntimeCapabilitiesService, SortBy, SortOrder, SortService, @@ -81,6 +83,11 @@ describe('RecentPlaylistsComponent busy state', () => { let playlistDeleteAction: { deletePlaylist: jest.Mock; }; + let playlistRefreshService: { + cancelRefresh: jest.Mock; + refreshPlaylist: jest.Mock; + }; + let runtimeIsElectron: boolean; let router: { navigate: jest.Mock; }; @@ -114,6 +121,14 @@ describe('RecentPlaylistsComponent busy state', () => { playlistDeleteAction = { deletePlaylist: jest.fn().mockResolvedValue(true), }; + playlistRefreshService = { + cancelRefresh: jest.fn().mockResolvedValue(undefined), + refreshPlaylist: jest.fn().mockResolvedValue({ + id: 'playlist-1', + items: [], + }), + }; + runtimeIsElectron = true; router = { navigate: jest.fn(), }; @@ -156,6 +171,18 @@ describe('RecentPlaylistsComponent busy state', () => { provide: PlaylistDeleteActionService, useValue: playlistDeleteAction, }, + { + provide: PlaylistRefreshService, + useValue: playlistRefreshService, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { + get isElectron() { + return runtimeIsElectron; + }, + }, + }, { provide: PlaylistContextFacade, useValue: { @@ -452,12 +479,7 @@ describe('RecentPlaylistsComponent busy state', () => { }); it('uses the legacy IPC refresh flow for non-Xtream playlists', () => { - window.electron = undefined as unknown as typeof window.electron; - ( - component as unknown as { - isElectron: boolean; - } - ).isElectron = false; + runtimeIsElectron = false; const item = createPlaylistMeta({ _id: 'playlist-m3u-1', serverUrl: undefined, @@ -474,4 +496,37 @@ describe('RecentPlaylistsComponent busy state', () => { filePath: item.filePath, }); }); + + it('re-evaluates Electron availability when refreshing local M3U playlists', async () => { + runtimeIsElectron = false; + const lateComponent = TestBed.createComponent( + RecentPlaylistsComponent + ).componentInstance; + runtimeIsElectron = true; + const item = createPlaylistMeta({ + _id: 'playlist-m3u-2', + serverUrl: undefined, + username: undefined, + password: undefined, + filePath: '/tmp/test.m3u', + }); + + lateComponent.refreshPlaylist(item); + + expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith( + { + operationId: 'playlist-refresh-op', + playlistId: item._id, + title: item.title, + url: item.url, + filePath: item.filePath, + }, + { + onEvent: expect.any(Function), + } + ); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + + await Promise.resolve(); + }); }); 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 aa74c8e6b..af0c90700 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 @@ -97,7 +97,9 @@ export class RecentPlaylistsComponent { readonly playlistClicked = output(); readonly addPlaylistClicked = output(); - readonly isElectron = this.runtime.isElectron; + get isElectron(): boolean { + return this.runtime.isElectron; + } readonly allPlaylistsLoaded = this.store.selectSignal( selectPlaylistsLoadingFlag @@ -292,7 +294,7 @@ export class RecentPlaylistsComponent { if (item.serverUrl) { // For Xtream playlists, delete and re-import this.refreshXtreamPlaylist(item); - } else if (window.electron && (item.url || item.filePath)) { + } else if (this.runtime.isElectron && (item.url || item.filePath)) { void this.refreshM3uPlaylist(item); } else { // For M3U playlists, use existing refresh logic diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 05c9e6951..3a4ba2373 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -23,6 +23,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsDownloads).toBe(false); expect(service.supportsManagedExternalPlayers).toBe(false); expect(service.supportsEmbeddedMpv).toBe(false); + expect(service.supportsDesktopFileSave).toBe(false); expect(service.supportsRemoteControl).toBe(false); }); @@ -34,6 +35,8 @@ describe('RuntimeCapabilitiesService', () => { dbSetAppState: jest.fn(), downloadsGetList: jest.fn(), prepareEmbeddedMpv: jest.fn(), + saveFileDialog: jest.fn(), + writeFile: jest.fn(), updateRemoteControlStatus: jest.fn(), onChannelChange: jest.fn(), onRemoteControlCommand: jest.fn(), @@ -49,6 +52,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsDownloads).toBe(true); expect(service.supportsManagedExternalPlayers).toBe(true); expect(service.supportsEmbeddedMpv).toBe(true); + expect(service.supportsDesktopFileSave).toBe(true); expect(service.supportsRemoteControl).toBe(true); }); @@ -64,6 +68,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsSqlite).toBe(false); expect(service.supportsDownloads).toBe(false); expect(service.supportsEmbeddedMpv).toBe(false); + expect(service.supportsDesktopFileSave).toBe(false); expect(service.supportsRemoteControl).toBe(false); }); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index f5a18580b..b530d4dc0 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -47,6 +47,13 @@ export class RuntimeCapabilitiesService { return this.hasElectronMethod('prepareEmbeddedMpv'); } + get supportsDesktopFileSave(): boolean { + return ( + this.hasElectronMethod('saveFileDialog') && + this.hasElectronMethod('writeFile') + ); + } + get supportsRemoteControl(): boolean { return ( this.hasElectronMethod('updateRemoteControlStatus') &&