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 a40f3da13..259726302 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 @@ -1168,6 +1168,52 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).componentInstance as StubAudioPlayerComponent; expect(audioPlayer.url()).toBe('http://portal.example/radio_2.mpg'); }); + + it('clears the radio header override when destroyed while the apply IPC is pending', async () => { + // Leaving the radio route mid-apply must not leave the portal + // cookie/token installed: ownership is claimed before awaiting the + // IPC, so ngOnDestroy can always name the stream to clear. + stalkerStore.selectedContentType.set('radio'); + selectedCategoryId.set('radio-all'); + selectedItem.set(null); + selectedItvId.set(undefined); + fixture.detectChanges(); + let resolveApply: (() => void) | undefined; + let signalApplyIssued!: () => void; + const applyIssued = new Promise( + (resolve) => (signalApplyIssued = resolve) + ); + // Once: only the apply call gets the pending promise — the destroy + // clear below also calls the bridge and must not steal the resolver. + ( + window.electron?.setUserAgent as jest.Mock + ).mockImplementationOnce(() => { + signalApplyIssued(); + return new Promise((resolve) => { + resolveApply = () => resolve(true); + }); + }); + resolveRadioPlayback.mockResolvedValue({ + streamUrl: 'http://portal.example/radio_2.mpg', + title: 'Portal FM', + headers: { Cookie: 'mac=00:1A:79:00:00:01' }, + }); + + const playPromise = component.playChannel(radioChannels()[0]); + // The header IPC has been issued but is still pending. + await applyIssued; + + fixture.destroy(); + resolveApply?.(); + await playPromise; + + expect(window.electron?.setUserAgent).toHaveBeenLastCalledWith( + undefined, + undefined, + 'http://portal.example/radio_2.mpg' + ); + expect(component.activePlayback()).toBeNull(); + }); }); function buildProgram(channelId: string, title: string): EpgProgram { 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 47a2c1641..a2f0c470f 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 @@ -560,8 +560,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); this.removeScrollListener(); - // Radio credentials must not outlive this layout; the service no-ops - // when a newer playback already owns the override slot. + // Invalidate any playback continuation still awaiting its header + // IPC, then drop the radio credentials — they must not outlive this + // layout. The service no-ops when a newer playback already owns the + // override slot. + this.playbackRequestId += 1; this.streamHeaders.clear(this.radioHeaderScopeUrl); } @@ -598,14 +601,21 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // WebPlayerViewComponent, so the scoped Electron header // override (portal cookie/token for auth-gated streams) must // be configured here BEFORE the audio element gets the URL. - await this.streamHeaders.apply(playback); + // Ownership is claimed synchronously, before awaiting the + // IPC: if this layout is destroyed while the apply is still + // in flight, ngOnDestroy must already know which stream's + // override to clear — otherwise the credentials would + // outlive the route. + const headerSync = this.streamHeaders.apply(playback); + this.radioHeaderScopeUrl = playback.streamUrl; + const stillCurrent = headerSync ? await headerSync : true; if ( + !stillCurrent || requestId !== this.playbackRequestId || this.selectedChannelId() !== channelId ) { return; } - this.radioHeaderScopeUrl = playback.streamUrl; this.activePlayback.set(playback); return; }