refactor(playlist-ui): use runtime capabilities

This commit is contained in:
4gray committed 2026-05-22 11:39:23 +03:00
1 parent 2fc3d4295f
commit 6ad61ce680
10 files changed
+366 -41

No files matched your search

@@ -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<string | null>(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(
@@ -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;
}
@@ -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<PlaylistInfoComponent>;
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);
});
});
@@ -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<Playlist & { id: string }>(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);
}
/**
@@ -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<PlaylistItemComponent>;
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();
});
});
@@ -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(
@@ -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();
});
});
@@ -97,7 +97,9 @@ export class RecentPlaylistsComponent {
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<PlaylistType | undefined>();
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