fix(xtream): preserve live search playback sidebar

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-08 21:05:10 +02:00
1 parent a3a4c6b5ea
commit 6efa8b9072
4 files changed
+236 -29

No files matched your search

+181 -3
View File
@@ -206,7 +206,10 @@ test.describe('Electron Workspace Search', () => {
await fillWorkspaceSearch(app.mainWindow, sample.groupTitle);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expectQueryParam(app.mainWindow, 'q', sample.groupTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
@@ -411,6 +414,134 @@ test.describe('Electron Workspace Search', () => {
}
});
test('@search @xtream filters VOD and series root views across categories', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodSample = await fetchXtreamVodFixture(request);
const seriesSample = await fetchXtreamSeriesFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await expectXtreamRootCatalogSearch(app.mainWindow, {
pathPattern: /\/workspace\/xtreams\/[^/]+\/vod$/,
sample: vodSample,
sectionLabel: 'Movies',
});
await expect(
app.mainWindow.locator(headerSearchSelector)
).toHaveValue(vodSample.targetTitle);
await fillWorkspaceSearch(app.mainWindow, '');
await openWorkspaceSection(app.mainWindow, 'Series');
await expectXtreamRootCatalogSearch(app.mainWindow, {
pathPattern: /\/workspace\/xtreams\/[^/]+\/series$/,
sample: seriesSample,
sectionLabel: 'Series',
});
await expect(
app.mainWindow.locator(headerSearchSelector)
).toHaveValue(seriesSample.targetTitle);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream filters live root and category scopes while preserving playback sidebar', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamLiveFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
'Live TV / All Items'
);
await expect(
liveChannelSidebar(app.mainWindow)
.locator('[data-test-id="channel-item"]')
.first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
await channelItemByTitle(app.mainWindow, sample.targetTitle)
.first()
.click();
await expect(
app.mainWindow.locator(
'app-live-stream-layout .content-container .video-player'
)
).toBeVisible({ timeout: 20000 });
await expect(
liveChannelSidebar(app.mainWindow)
.locator('[data-test-id="channel-item"]')
.first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toHaveClass(/(^|\s)active(\s|$)/);
await fillWorkspaceSearch(app.mainWindow, '');
await clickCategoryByNameExact(app.mainWindow, sample.categoryName);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live(?:\/[^/]+)?$/
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
`Live TV / ${sample.categoryName}`
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream promotes dashboard header search into advanced playlist search', async ({
dataDir,
request,
@@ -561,7 +692,10 @@ test.describe('Electron Workspace Search', () => {
);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(
contentCardByTitle(
app.mainWindow,
@@ -801,7 +935,10 @@ test.describe('Electron Workspace Search', () => {
await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
@@ -911,6 +1048,15 @@ async function fetchXtreamVodFixture(
});
}
async function fetchXtreamLiveFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
return fetchXtreamFixture(request, {
categoriesAction: 'get_live_categories',
itemsAction: 'get_live_streams',
});
}
async function fetchXtreamSeriesFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
@@ -1262,6 +1408,10 @@ function xtreamSearchResultCards(page: Page) {
return page.locator('app-search-results .results-grid .content-card');
}
function liveChannelSidebar(page: Page) {
return page.locator('app-live-stream-layout .sidebar');
}
function gridListCardByTitle(page: Page, title: string) {
return page.locator('.category-content-layout mat-card').filter({
has: page.locator('.title', {
@@ -1329,6 +1479,34 @@ async function resolveVisibleXtreamTitles(
};
}
async function expectXtreamRootCatalogSearch(
page: Page,
options: {
pathPattern: RegExp;
sample: XtreamVodFixture;
sectionLabel: string;
}
): Promise<void> {
await openWorkspaceSection(page, options.sectionLabel);
await expectPathname(page, options.pathPattern);
await fillWorkspaceSearch(page, options.sample.targetTitle);
await expectPathname(page, options.pathPattern);
await expectQueryParam(page, 'q', options.sample.targetTitle);
await expectWorkspaceSearchScope(
page,
`${options.sectionLabel} / All Items`
);
const contentLayout = page.locator('.category-content-layout');
await expect(contentLayout).toContainText(
flexibleTextPattern(options.sample.targetTitle),
{ timeout: 20000 }
);
await expect(contentLayout).not.toContainText(
flexibleTextPattern(options.sample.controlTitle)
);
}
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.locator('button.favorite-btn')
@@ -1,7 +1,7 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@let streamUrl = activeStreamUrl();
@if (selectedCategoryId()) {
@if (showLiveChannelSidebar()) {
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
@@ -14,7 +14,7 @@
<div class="sidebar-header">
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }}
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
@@ -91,22 +91,7 @@
<mat-icon>chevron_right</mat-icon>
</button>
}
@if (!selectedCategoryId()) {
@if (isSelectedTypeContentLoading() || workspaceSearchTerm()) {
<div class="live-loading-content">
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
/>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
}
} @else if (streamUrl) {
@if (streamUrl) {
@if (isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view
@@ -148,6 +133,21 @@
/>
}
</div>
} @else if (!showLiveChannelSidebar()) {
@if (isSelectedTypeContentLoading() || workspaceSearchTerm()) {
<div class="live-loading-content">
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
/>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
}
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -355,17 +355,47 @@ describe('LiveStreamLayoutComponent', () => {
fixture.detectChanges();
const list = fixture.debugElement.query(
const lists = fixture.debugElement.queryAll(
By.directive(StubPortalChannelsListComponent)
);
const list = lists[0];
expect(list).not.toBeNull();
expect(lists).toHaveLength(1);
expect(list.componentInstance.searchTermInput() as string).toBe(
'world'
);
expect(
fixture.nativeElement.querySelector('app-portal-empty-state')
).toBeNull();
fixture.nativeElement.querySelector(
'.sidebar [data-test-id="portal-channels-list-stub"]'
)
).not.toBeNull();
});
it('shows embedded playback after selecting a channel from live root search results', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
routeQueryParamMap.next(convertToParamMap({ q: 'world' }));
fixture.detectChanges();
const list = fixture.debugElement.query(
By.directive(StubPortalChannelsListComponent)
);
list.componentInstance.playClicked.emit(sampleChannel);
fixture.detectChanges();
expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith(
sampleChannel
);
expect(
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
).not.toBeNull();
expect(
fixture.nativeElement.querySelector(
'.sidebar [data-test-id="portal-channels-list-stub"]'
)
).not.toBeNull();
});
it('renders the current EPG program in the collapsible panel summary', () => {
@@ -53,11 +53,7 @@ import {
LiveEpgPanelSummary,
WebPlayerViewComponent,
} from 'shared-portals';
import {
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { EpgItem, EpgProgram, ResolvedPortalPlayback } from 'shared-interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute } from '@angular/router';
@@ -124,6 +120,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
readonly showLiveChannelSidebar = computed(
() => !!this.selectedCategoryId() || !!this.workspaceSearchTerm()
);
private readonly pendingAutoOpenLiveItemId = signal<number | null>(null);
readonly selectedLiveItem = computed<XtreamLiveChannelItem | null>(() => {
if (this.xtreamStore.selectedContentType() !== 'live') {