From 9b8f58b84a54b387765f9baf95c66e657fba9c9b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 14:08:38 +0200 Subject: [PATCH] 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 --- docs/architecture/parental-lock.md | 7 ++- .../stalker-itv-all-items.component.spec.ts | 5 +- .../stalker-live-lock-guard.spec.ts | 16 +++++ .../stalker-live-lock-guard.ts | 15 +++++ ...alker-live-stream-layout.component.spec.ts | 60 +++++++++++-------- .../stalker-live-stream-layout.component.ts | 19 +++++- 6 files changed, 88 insertions(+), 34 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.ts diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 749643bb2..c86bc4570 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts index 005163aa1..f676cc8c3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-itv-all-items.component.spec.ts @@ -20,10 +20,7 @@ describe('StalkerItvAllItemsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - StalkerItvAllItemsComponent, - TranslateModule.forRoot(), - ], + imports: [StalkerItvAllItemsComponent, TranslateModule.forRoot()], }).compileComponents(); fixture = TestBed.createComponent(StalkerItvAllItemsComponent); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.spec.ts new file mode 100644 index 000000000..58ba99ae1 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.spec.ts @@ -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); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.ts new file mode 100644 index 000000000..329abf94a --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-lock-guard.ts @@ -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; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index f58110ea6..56e792460 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -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([]) diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 266e2b3e5..f38b6b715 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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); 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