diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts index a5374e029..b3375312f 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts @@ -1,9 +1,12 @@ import { + inject, ChangeDetectionStrategy, Component, input, } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { routeParamSignal } from '@iptvnator/portal/shared/util'; @Component({ selector: 'app-m3u-collection-route', @@ -12,11 +15,19 @@ import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class M3uCollectionRouteComponent { + private readonly route = inject(ActivatedRoute); + readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input('m3u'); + readonly playlistId = routeParamSignal( + this.route, + 'id', + (value) => value ?? undefined + ); } diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts new file mode 100644 index 000000000..4c598fec1 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.spec.ts @@ -0,0 +1,268 @@ +import { signal } from '@angular/core'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flushMicrotasks, + tick, +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { PortalStatusService } from 'services'; +import { PlaylistMeta } from 'shared-interfaces'; +import { PlaylistSwitcherComponent } from './playlist-switcher.component'; + +const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query'; +const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters'; + +function createPlaylist( + overrides: Partial & { _id: string } +): PlaylistMeta { + return { + _id: overrides._id, + title: overrides.title ?? overrides.filename ?? overrides._id, + filename: overrides.filename, + count: overrides.count ?? 0, + importDate: + overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(), + autoRefresh: overrides.autoRefresh ?? false, + ...overrides, + } as PlaylistMeta; +} + +describe('PlaylistSwitcherComponent', () => { + let fixture: ComponentFixture; + let component: PlaylistSwitcherComponent; + let playlistsSignal: ReturnType>; + let resolvedPlaylistIdSignal: ReturnType>; + let activePlaylistSignal: ReturnType>; + let playlistContext: { + playlists: typeof playlistsSignal; + allPlaylistsLoaded: ReturnType>; + resolvedPlaylistId: typeof resolvedPlaylistIdSignal; + activePlaylist: typeof activePlaylistSignal; + selectPlaylist: jest.Mock; + }; + let portalStatusService: { + checkPortalStatus: jest.Mock; + getStatusClass: jest.Mock; + }; + + const m3uPlaylist = createPlaylist({ + _id: 'm3u-1', + count: 24, + importDate: '2026-04-05T09:00:00.000Z', + title: 'Morning M3U', + url: 'http://example.test/m3u.m3u', + }); + const stalkerPlaylist = createPlaylist({ + _id: 'stalker-1', + importDate: '2026-04-05T11:00:00.000Z', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://127.0.0.1:3210/portal.php', + title: 'Living Room Stalker', + }); + const xtreamPlaylist = createPlaylist({ + _id: 'xtream-1', + importDate: '2026-04-05T12:00:00.000Z', + password: 'pass1', + serverUrl: 'http://127.0.0.1:3211', + title: 'Cinema Xtream', + updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(), + username: 'user1', + }); + + async function createComponent(): Promise { + await TestBed.configureTestingModule({ + imports: [ + PlaylistSwitcherComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: PlaylistContextFacade, + useValue: playlistContext, + }, + { + provide: PortalStatusService, + useValue: portalStatusService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PlaylistSwitcherComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('currentTitle', ''); + fixture.componentRef.setInput('subtitle', '3 playlists'); + fixture.detectChanges(); + } + + beforeEach(() => { + localStorage.clear(); + + playlistsSignal = signal([m3uPlaylist, stalkerPlaylist, xtreamPlaylist]); + resolvedPlaylistIdSignal = signal(xtreamPlaylist._id); + activePlaylistSignal = signal(xtreamPlaylist); + + playlistContext = { + playlists: playlistsSignal, + allPlaylistsLoaded: signal(true), + resolvedPlaylistId: resolvedPlaylistIdSignal, + activePlaylist: activePlaylistSignal, + selectPlaylist: jest.fn((playlist: PlaylistMeta) => { + resolvedPlaylistIdSignal.set(playlist._id); + activePlaylistSignal.set(playlist); + }), + }; + portalStatusService = { + checkPortalStatus: jest + .fn() + .mockResolvedValueOnce('active') + .mockResolvedValueOnce('inactive'), + getStatusClass: jest.fn((status: string) => `status-${status}`), + }; + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it('reads persisted search and type filters on startup and filters playlists accordingly', async () => { + localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room'); + localStorage.setItem( + PLAYLIST_TYPE_FILTER_STORAGE_KEY, + JSON.stringify({ + m3u: false, + stalker: true, + xtream: true, + }) + ); + + await createComponent(); + + expect(component.searchQuery()).toBe('room'); + expect(component.isTypeFilterSelected('m3u')).toBe(false); + expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]); + }); + + it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => { + await createComponent(); + + expect(component.filteredPlaylists().map((playlist) => playlist._id)).toEqual([ + xtreamPlaylist._id, + stalkerPlaylist._id, + m3uPlaylist._id, + ]); + + component.togglePlaylistTypeFilter('m3u'); + component.togglePlaylistTypeFilter('stalker'); + expect(component.playlistTypeFilters()).toEqual({ + m3u: false, + stalker: false, + xtream: true, + }); + + component.togglePlaylistTypeFilter('xtream'); + expect(component.playlistTypeFilters()).toEqual({ + m3u: false, + stalker: false, + xtream: true, + }); + }); + + it('selects a playlist, closes the menu, and emits the selected id', async () => { + await createComponent(); + + const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); + const selectedIds: string[] = []; + component.playlistSelected.subscribe((playlistId) => { + selectedIds.push(playlistId); + }); + + component.selectPlaylist(stalkerPlaylist); + fixture.detectChanges(); + + expect(closeMenuSpy).toHaveBeenCalledTimes(1); + expect(playlistContext.selectPlaylist).toHaveBeenCalledWith( + stalkerPlaylist + ); + expect(selectedIds).toEqual([stalkerPlaylist._id]); + expect(component.isSelected(stalkerPlaylist)).toBe(true); + }); + + it('emits playlist and account info requests through the context actions', async () => { + await createComponent(); + + const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu'); + const playlistInfoSpy = jest.fn(); + const accountInfoSpy = jest.fn(); + component.playlistInfoRequested.subscribe(playlistInfoSpy); + component.accountInfoRequested.subscribe(accountInfoSpy); + + component.requestPlaylistInfo(); + component.requestAccountInfo(); + + expect(closeMenuSpy).toHaveBeenCalledTimes(2); + expect(playlistInfoSpy).toHaveBeenCalledTimes(1); + expect(accountInfoSpy).toHaveBeenCalledTimes(1); + }); + + it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => { + await createComponent(); + + const triggerElement = component.triggerElement().nativeElement; + jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({ + bottom: 0, + height: 0, + left: 0, + right: 320, + toJSON: () => ({}), + top: 0, + width: 320, + x: 0, + y: 0, + }); + + component.onMenuOpened(); + flushMicrotasks(); + tick(); + + expect(component.isMenuOpen()).toBe(true); + expect( + document.documentElement.style.getPropertyValue( + '--playlist-switcher-overlay-width' + ) + ).toBe('320px'); + expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1); + expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith( + xtreamPlaylist.serverUrl, + xtreamPlaylist.username, + xtreamPlaylist.password + ); + expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe('active'); + expect(component.getStatusClass(xtreamPlaylist._id)).toBe('status-active'); + + component.onMenuClosed(); + expect(component.isMenuOpen()).toBe(false); + expect( + document.documentElement.style.getPropertyValue( + '--playlist-switcher-overlay-width' + ) + ).toBe(''); + })); + + it('falls back to the active playlist title when no explicit current title is provided', async () => { + await createComponent(); + + expect(component.displayTitle()).toBe('Cinema Xtream'); + + resolvedPlaylistIdSignal.set(null); + activePlaylistSignal.set(null); + fixture.detectChanges(); + + expect(component.displayTitle()).toBe('Select playlist'); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts b/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts new file mode 100644 index 000000000..a78d29105 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-context.facade.spec.ts @@ -0,0 +1,422 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { + PlaylistActions, + selectActivePlaylistId, + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from 'm3u-state'; +import { Subject } from 'rxjs'; +import { PlaylistMeta } from 'shared-interfaces'; +import { + PlaylistContextFacade, + PlaylistRouteContext, +} from './playlist-context.facade'; + +const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2'; + +function createPlaylist( + overrides: Partial & { _id: string } +): PlaylistMeta { + return { + _id: overrides._id, + title: overrides.title ?? overrides.filename ?? overrides._id, + count: overrides.count ?? 0, + importDate: + overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(), + autoRefresh: overrides.autoRefresh ?? false, + ...overrides, + } as PlaylistMeta; +} + +describe('PlaylistContextFacade', () => { + let facade: PlaylistContextFacade; + let routerEvents: Subject; + let router: { + url: string; + events: ReturnType['asObservable']>; + navigate: jest.Mock; + }; + let dispatch: jest.Mock; + let playlistsSignal: ReturnType>; + let loadedSignal: ReturnType>; + let activePlaylistIdSignal: ReturnType>; + let originalElectron: unknown; + + const xtreamA = createPlaylist({ + _id: 'xtream-a', + password: 'secret', + serverUrl: 'http://127.0.0.1:3211', + title: 'Xtream A', + username: 'user-a', + }); + const xtreamB = createPlaylist({ + _id: 'xtream-b', + password: 'secret', + serverUrl: 'http://127.0.0.1:3211', + title: 'Xtream B', + username: 'user-b', + }); + const stalkerA = createPlaylist({ + _id: 'stalker-a', + macAddress: '00:1A:79:00:00:01', + portalUrl: 'http://127.0.0.1:3210/portal.php', + title: 'Stalker A', + }); + const stalkerB = createPlaylist({ + _id: 'stalker-b', + macAddress: '00:1A:79:00:00:02', + portalUrl: 'http://127.0.0.1:3210/portal.php', + title: 'Stalker B', + }); + const m3uA = createPlaylist({ + _id: 'm3u-a', + title: 'M3U A', + url: 'http://example.test/a.m3u', + }); + const m3uB = createPlaylist({ + _id: 'm3u-b', + title: 'M3U B', + url: 'http://example.test/b.m3u', + }); + + function setElectronAvailability(enabled: boolean): void { + Object.defineProperty(window, 'electron', { + configurable: true, + value: enabled ? {} : undefined, + }); + } + + function instantiateFacade(): PlaylistContextFacade { + facade = TestBed.inject(PlaylistContextFacade); + dispatch.mockClear(); + router.navigate.mockClear(); + return facade; + } + + beforeEach(() => { + localStorage.clear(); + originalElectron = (window as Window & { electron?: unknown }).electron; + + routerEvents = new Subject(); + router = { + url: '/workspace/xtreams/xtream-a/live', + events: routerEvents.asObservable(), + navigate: jest.fn().mockResolvedValue(true), + }; + dispatch = jest.fn((action: unknown) => action); + playlistsSignal = signal([ + xtreamA, + xtreamB, + stalkerA, + stalkerB, + m3uA, + m3uB, + ]); + loadedSignal = signal(true); + activePlaylistIdSignal = signal(xtreamA._id); + setElectronAvailability(true); + + TestBed.configureTestingModule({ + providers: [ + PlaylistContextFacade, + { + provide: Router, + useValue: router, + }, + { + provide: Store, + useValue: { + dispatch, + selectSignal: jest.fn((selector: unknown) => { + if (selector === selectAllPlaylistsMeta) { + return playlistsSignal; + } + if (selector === selectPlaylistsLoadingFlag) { + return loadedSignal; + } + if (selector === selectActivePlaylistId) { + return activePlaylistIdSignal; + } + + throw new Error( + `Unexpected selector: ${String(selector)}` + ); + }), + }, + }, + ], + }); + }); + + afterEach(() => { + localStorage.clear(); + Object.defineProperty(window, 'electron', { + configurable: true, + value: originalElectron, + }); + routerEvents.complete(); + jest.restoreAllMocks(); + }); + + it('preserves same-provider Xtream sections when switching through selectPlaylist', () => { + router.url = '/workspace/xtreams/xtream-a/live'; + const service = instantiateFacade(); + + service.selectPlaylist(xtreamB); + + expect(dispatch).toHaveBeenCalledWith( + PlaylistActions.setActivePlaylist({ playlistId: xtreamB._id }) + ); + expect(router.navigate).toHaveBeenCalledWith([ + 'workspace', + 'xtreams', + xtreamB._id, + 'live', + ]); + }); + + it('preserves same-provider Stalker sections when resolving target commands', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerB._id, { + inWorkspace: true, + provider: 'stalker', + playlistId: stalkerA._id, + section: 'downloads', + }) + ).toEqual(['workspace', 'stalker', stalkerB._id, 'downloads']); + }); + + it('preserves same-provider M3U sections when resolving target commands', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uB._id, { + inWorkspace: true, + provider: 'playlists', + playlistId: m3uA._id, + section: 'groups', + }) + ).toEqual(['workspace', 'playlists', m3uB._id, 'groups']); + }); + + it('maps Stalker ITV routes to Xtream live routes', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamA._id, { + inWorkspace: true, + provider: 'stalker', + playlistId: stalkerA._id, + section: 'itv', + }) + ).toEqual(['workspace', 'xtreams', xtreamA._id, 'live']); + }); + + it('maps Xtream live routes to Stalker ITV routes', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'stalker', stalkerA._id, 'itv']); + }); + + it('maps Xtream recently-added routes to Stalker recent routes', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'recently-added', + }) + ).toEqual(['workspace', 'stalker', stalkerA._id, 'recent']); + }); + + it('preserves recent and favorites sections across provider changes when both providers support them', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamA._id, { + inWorkspace: true, + provider: 'playlists', + playlistId: m3uA._id, + section: 'recent', + }) + ).toEqual(['workspace', 'xtreams', xtreamA._id, 'recent']); + expect( + service.resolveTargetCommands('stalker', stalkerA._id, { + inWorkspace: true, + provider: 'playlists', + playlistId: m3uA._id, + section: 'favorites', + }) + ).toEqual(['workspace', 'stalker', stalkerA._id, 'favorites']); + }); + + it('falls back to M3U all when the current section has no M3U equivalent', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'playlists', m3uA._id, 'all']); + }); + + it('does not let stale M3U memory override the fallback to all for portal-only sections', () => { + localStorage.setItem( + LAST_SECTION_STORAGE_KEY, + JSON.stringify({ + providers: { + playlists: 'recent', + }, + playlists: { + [m3uA._id]: { + provider: 'playlists', + section: 'recent', + updatedAt: 1, + }, + }, + }) + ); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uA._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'playlists', m3uA._id, 'all']); + }); + + it('uses playlist-specific section memory when the current route has no section', () => { + localStorage.setItem( + LAST_SECTION_STORAGE_KEY, + JSON.stringify({ + providers: {}, + playlists: { + [xtreamB._id]: { + provider: 'xtreams', + section: 'series', + updatedAt: 1, + }, + }, + }) + ); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamB._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: null, + }) + ).toEqual(['workspace', 'xtreams', xtreamB._id, 'series']); + }); + + it('uses provider-level section memory when no route section or playlist memory exists', () => { + localStorage.setItem( + LAST_SECTION_STORAGE_KEY, + JSON.stringify({ + providers: { + stalker: 'search', + }, + playlists: {}, + }) + ); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('stalker', stalkerB._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: null, + }) + ).toEqual(['workspace', 'stalker', stalkerB._id, 'search']); + }); + + it('omits Xtream sections outside Electron where section navigation is unsupported', () => { + setElectronAvailability(false); + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('xtreams', xtreamB._id, { + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'live', + }) + ).toEqual(['workspace', 'xtreams', xtreamB._id]); + }); + + it('returns null when switching from the workspace root before a provider route is selected', () => { + const service = instantiateFacade(); + + expect( + service.resolveTargetCommands('playlists', m3uA._id, { + inWorkspace: true, + provider: null, + playlistId: null, + section: null, + }) + ).toBeNull(); + }); + + it('syncs section memory from router navigation events', () => { + router.url = '/workspace/playlists/m3u-a/favorites'; + const service = instantiateFacade(); + + router.url = '/workspace/xtreams/xtream-a/recently-added'; + routerEvents.next( + new NavigationEnd( + 1, + '/workspace/xtreams/xtream-a/recently-added', + '/workspace/xtreams/xtream-a/recently-added' + ) + ); + + expect(service.routeContext()).toEqual({ + inWorkspace: true, + provider: 'xtreams', + playlistId: xtreamA._id, + section: 'recently-added', + }); + expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}')) + .toEqual({ + playlists: { + [m3uA._id]: { + provider: 'playlists', + section: 'favorites', + updatedAt: expect.any(Number), + }, + [xtreamA._id]: { + provider: 'xtreams', + section: 'recently-added', + updatedAt: expect.any(Number), + }, + }, + providers: { + playlists: 'favorites', + xtreams: 'recently-added', + }, + }); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/playlist-context.facade.ts b/libs/playlist/shared/util/src/lib/playlist-context.facade.ts index 1f45d6056..99ee469c6 100644 --- a/libs/playlist/shared/util/src/lib/playlist-context.facade.ts +++ b/libs/playlist/shared/util/src/lib/playlist-context.facade.ts @@ -312,6 +312,10 @@ export class PlaylistContextFacade { return fromCurrent; } + if (provider === 'playlists' && currentSection) { + return 'all'; + } + const memory = this.readSectionMemory(); const playlistMemory = memory.playlists[targetPlaylistId]; if (playlistMemory?.provider === provider) {