fix(settings): retire a resolving Stalker live playback when the session relocks

The embedded player defers selecting the channel until its stream
resolves, so the enforcement service's cleared selection could not retire
the request; it now carries the lock version it was issued under and is
dropped when a relock happened meanwhile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 14:08:38 +02:00
1 parent efece8628f
commit 9b8f58b84a
6 files changed
+88 -34

No files matched your search

+6 -1
View File
@@ -209,7 +209,12 @@ on either side.
from `*` (All) or search is withheld by its genre even though `*` itself
can never be locked. A withheld item is cleared and the section root is
navigated to; the category selection is reset only when the genre itself
is locked. `StalkerLiveStreamLayoutComponent` drops its `activePlayback`
is locked. A stream still RESOLVING at relock time is retired too: the
embedded player defers selecting the channel until resolution, so the
cleared selection cannot retire it — the playback request carries the
lock version it was issued under (`isStalkerPlaybackRequestLockCurrent`)
and is dropped when the session relocked meanwhile (an unlock lets it
complete). `StalkerLiveStreamLayoutComponent` drops its `activePlayback`
whenever the store selection is cleared: its template mounts the player
only with a selection, and the held stream must not resurface with the
next one.
@@ -20,10 +20,7 @@ describe('StalkerItvAllItemsComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
StalkerItvAllItemsComponent,
TranslateModule.forRoot(),
],
imports: [StalkerItvAllItemsComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(StalkerItvAllItemsComponent);
@@ -0,0 +1,16 @@
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
describe('isStalkerPlaybackRequestLockCurrent', () => {
it('lets an unchanged lock version through', () => {
expect(isStalkerPlaybackRequestLockCurrent(3, 3, true)).toBe(true);
expect(isStalkerPlaybackRequestLockCurrent(3, 3, false)).toBe(true);
});
it('retires a request the session relocked over', () => {
expect(isStalkerPlaybackRequestLockCurrent(3, 4, true)).toBe(false);
});
it('lets a request through when the change was an unlock', () => {
expect(isStalkerPlaybackRequestLockCurrent(3, 4, false)).toBe(true);
});
});
@@ -0,0 +1,15 @@
/**
* Whether a playback request issued under parental-lock `requested` may
* still complete now that the lock is at `current`. The embedded player
* defers selecting the channel until its stream resolves, so the
* enforcement service's clearing of the selection cannot retire the
* request; a relock in between must. An UNLOCK in between is harmless — the
* request completes.
*/
export function isStalkerPlaybackRequestLockCurrent(
requested: number,
current: number,
active: boolean
): boolean {
return requested === current || !active;
}
@@ -20,6 +20,7 @@ import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import {
ParentalLockService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
@@ -283,6 +284,10 @@ describe('StalkerLiveStreamLayoutComponent', () => {
},
{ provide: PlaylistsService, useValue: playlistService },
{ provide: SettingsStore, useValue: settingsStore },
{
provide: ParentalLockService,
useValue: { version: signal(0), active: signal(false) },
},
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
{
provide: TranslateService,
@@ -782,35 +787,38 @@ describe('StalkerLiveStreamLayoutComponent', () => {
).toEqual([]);
});
it.each([true, false])('grows the render window for remote/numeric selection (cached=%s)', async (cached) => {
const full = Array.from({ length: 250 }, (_, index) => ({
id: `ch-${index}`,
cmd: `ffrt4://itv/${index}`,
name: `Channel ${index}`,
o_name: `Channel ${index}`,
logo: '',
}));
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(cached);
itvChannels.set(full);
itvFullChannelList.set(cached ? full : []);
searchPhrase.set('channel');
fixture.detectChanges();
it.each([true, false])(
'grows the render window for remote/numeric selection (cached=%s)',
async (cached) => {
const full = Array.from({ length: 250 }, (_, index) => ({
id: `ch-${index}`,
cmd: `ffrt4://itv/${index}`,
name: `Channel ${index}`,
o_name: `Channel ${index}`,
logo: '',
}));
itvFullListActive.set(true);
itvSelectedCategoryFromCache.set(cached);
itvChannels.set(full);
itvFullChannelList.set(cached ? full : []);
searchPhrase.set('channel');
fixture.detectChanges();
expect(component.visibleChannels()).toHaveLength(100);
expect(component.visibleChannels()).toHaveLength(100);
// Selecting channel index 150 (outside the 100-item window) must grow
// the window so it is rendered and can be highlighted/scrolled to.
await component.playChannel(full[150], false);
fixture.detectChanges();
// Selecting channel index 150 (outside the 100-item window) must grow
// the window so it is rendered and can be highlighted/scrolled to.
await component.playChannel(full[150], false);
fixture.detectChanges();
expect(component.visibleChannels().length).toBeGreaterThan(150);
expect(
component
.visibleChannels()
.some((channel) => channel.id === 'ch-150')
).toBe(true);
});
expect(component.visibleChannels().length).toBeGreaterThan(150);
expect(
component
.visibleChannels()
.some((channel) => channel.id === 'ch-150')
).toBe(true);
}
);
it('does not clear cached channels when switching category in full-list mode', async () => {
// Regression: the category-change reset effect used to setItvChannels([])
@@ -37,11 +37,13 @@ import {
ResizableDirective,
} from '@iptvnator/ui/components';
import {
ParentalLockService,
PlaylistsService,
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { isStalkerPlaybackRequestLockCurrent } from './stalker-live-lock-guard';
import {
foldSearchText,
buildStalkerEpgMappingKey,
@@ -125,6 +127,8 @@ interface StalkerPlaybackResolutionOwner {
readonly sourceId: string;
readonly contentType: string;
readonly channelId: string;
/** `ParentalLockService.version` when the request was issued. */
readonly lockVersion: number;
}
/** Channels rendered per "page" when the full list is served from the cache. */
@@ -181,6 +185,7 @@ export class StalkerLiveStreamLayoutComponent
implements OnDestroy, FullscreenChannelPanelHost
{
readonly stalkerStore = inject(StalkerStore);
private readonly parentalLock = inject(ParentalLockService);
private readonly playlistService = inject(PlaylistsService);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly dialog = inject(MatDialog);
@@ -930,6 +935,7 @@ export class StalkerLiveStreamLayoutComponent
this.stalkerStore.currentPlaylist()?._id
);
const contentType = this.stalkerStore.selectedContentType();
const lockVersion = this.parentalLock.version();
const isRadioMode = this.isRadioMode();
const deferSelection = !isRadioMode && this.usesEmbeddedPlayer();
// Inline video retains its stream during resolution. Keep selection,
@@ -947,10 +953,10 @@ export class StalkerLiveStreamLayoutComponent
try {
const playback = await this.resolvePlaybackForChannel(
item,
{ sourceId, contentType, channelId },
{ sourceId, contentType, channelId, lockVersion },
isRadioMode
);
const owner = { sourceId, contentType, channelId };
const owner = { sourceId, contentType, channelId, lockVersion };
if (
!this.isPlaybackRequestCurrent(
requestId,
@@ -1012,7 +1018,7 @@ export class StalkerLiveStreamLayoutComponent
if (
!this.isPlaybackRequestCurrent(
requestId,
{ sourceId, contentType, channelId },
{ sourceId, contentType, channelId, lockVersion },
expectedSelectedId
)
) {
@@ -1062,6 +1068,13 @@ export class StalkerLiveStreamLayoutComponent
): boolean {
return (
requestId === this.playbackRequestId &&
// A relock while the stream resolved: the deferred selection
// means the enforcement service had nothing to clear yet.
isStalkerPlaybackRequestLockCurrent(
owner.lockVersion,
this.parentalLock.version(),
this.parentalLock.active()
) &&
this.selectedChannelId() === expectedSelectedId &&
normalizeStalkerEntityId(
this.stalkerStore.currentPlaylist()?._id