fix(settings): drop withheld Stalker search rows at relock time and keep the M3U unlock row when every group is locked

- A relock during a page-1 Stalker search now filters the rows already on
  screen at once, so old unlocked results are not clickable while the
  replacement page is pending.
- When every M3U group is locked the groups rail still renders, with its
  "N locked · Enter PIN to show" row, instead of the plain empty state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 09:03:07 +02:00
1 parent cdc79da768
commit 3b892f4114
5 files changed
+63 -14

No files matched your search

+4
View File
@@ -255,6 +255,10 @@ on either side.
`stalkerWithheldRowKey` (`id`, else `stream_id`/`movie_id`/`series_id`,
else the row's `cmd`/`name`), never by a shared `''`, so a page of new
locked rows is not mistaken for a stalled portal.
- **Stalker search relock:** a lock change drops the withheld rows already
on screen at once (`applyRelockToResults`, page 1 included) and closes an
open detail of a now-withheld genre, before the replacement page is
awaited — otherwise they stay clickable while that request is pending.
- **Stalker search staleness:** portal requests are not aborted, so a
search page issued before a relock can finish after it, filtered with the
pre-relock withheld set; `isStalkerSearchRequestCurrent` keys the
@@ -460,6 +460,24 @@ describe('StalkerSearchComponent result paging', () => {
expect(component.searchPage()).toBe(pageBefore + 1);
});
it('drops withheld rows on screen at relock time, page 1 included', () => {
component.applySearchPageSuccess(
1,
[
{ id: 'news-1', name: 'News', category_id: '5' },
{ id: 'adult-1', name: 'Adult', category_id: '9' },
],
2
);
expect(component.searchResults()).toHaveLength(2);
component.applyRelockToResults(new Set(['9']), 'vod');
expect(component.searchResults().map((item) => item.id)).toEqual([
'news-1',
]);
});
it('closes an open detail whose genre became withheld on relock', () => {
component.selectItem({ id: 'adult-9', name: 'A', category_id: '9' });
expect(component.itemDetails()).not.toBeNull();
@@ -297,22 +297,13 @@ export class StalkerSearchComponent {
this.searchResultsLockVersion !== params.parentalLockVersion;
this.searchResultsLockVersion = params.parentalLockVersion;
if (lockVersionChanged) {
// A relock must also close an open detail of a genre that is
// withheld now; the list alone hiding it is not enough.
this.closeWithheldDetail(withheldCategoryIds);
this.applyRelockToResults(withheldCategoryIds, contentType);
}
if (lockVersionChanged && params.page > 1) {
// A lock flip past page 1: drop the withheld rows on screen
// and rebuild from page 1 rather than appending to pages
// accumulated under the old lock state.
const retained = withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
withheldCategoryIds
);
this.accumulatedSearchResults.set(retained);
// A lock flip past page 1: rebuild from page 1 rather than
// appending to pages accumulated under the old lock state.
this.searchPage.set(1);
return retained;
return this.accumulatedSearchResults();
}
const withheldKey = JSON.stringify([
params.playlistId,
@@ -743,6 +734,27 @@ export class StalkerSearchComponent {
* lock (Lock now, idle relock): the title, its playback actions and the
* store's selected item must not outlive the list row.
*/
/**
* A lock change reached the results on screen, which were read under
* the old lock state: close an open detail of a now-withheld genre (the
* list hiding it is not enough) and drop the withheld rows NOW, before
* the replacement page is awaited — on page 1 too, or they stay
* clickable while (or, if it hangs, after) that request is pending.
*/
applyRelockToResults(
withheldCategoryIds: ReadonlySet<string>,
contentType: Parameters<typeof withoutWithheldStalkerItems>[1]
): void {
this.closeWithheldDetail(withheldCategoryIds);
this.accumulatedSearchResults.set(
withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
withheldCategoryIds
)
);
}
closeWithheldDetail(withheldCategoryIds: ReadonlySet<string>): void {
const details = this.itemDetails();
const categoryId = details?.category_id;
@@ -1,4 +1,4 @@
@if (!hasAnyGroups()) {
@if (!hasAnyGroups() && withheldGroupCount() === 0) {
<div class="groups-view-empty-state">
<mat-icon class="empty-state-icon">{{
searchTerm() ? 'search_off' : 'folder_off'
@@ -580,6 +580,21 @@ describe('GroupsViewComponent', () => {
expect(parentalLock.requestUnlock).toHaveBeenCalled();
});
it('keeps the rail and its unlock row when every group is withheld', () => {
fixture.componentRef.setInput('groupedChannels', {});
fixture.componentRef.setInput('withheldGroupCount', 3);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.groups-view-empty-state')
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="groups-locked-row"]'
)
).not.toBeNull();
});
it('offers no group lock menu while the feature is off', () => {
const event = new MouseEvent('contextmenu', { cancelable: true });
component.onGroupContextMenu('Sports', event);