mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
1 parent
efece8628f
commit
9b8f58b84a
6 files changed
+88
-34
No files matched your search
@@ -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.
|
||||
|
||||
+1
-4
@@ -20,10 +20,7 @@ describe('StalkerItvAllItemsComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
StalkerItvAllItemsComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
imports: [StalkerItvAllItemsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(StalkerItvAllItemsComponent);
|
||||
|
||||
+16
@@ -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);
|
||||
});
|
||||
});
|
||||
+15
@@ -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;
|
||||
}
|
||||
+34
-26
@@ -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([])
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user