fix(playback): address inline collection review feedback

This commit is contained in:
4gray committed 2026-05-12 10:53:07 +02:00
1 parent b12197c520
commit 18ee78c433
7 files changed
+276 -203

No files matched your search

@@ -2,6 +2,8 @@ import { PlaybackPositionData, XtreamSerieEpisode } from 'shared-interfaces';
import {
SERIES_QUICK_START_ACTION_KIND,
getSeriesQuickStartAction,
getPositiveInteger,
padEpisodePart,
} from './series-quick-start';
function episode(
@@ -243,3 +245,17 @@ describe('getSeriesQuickStartAction', () => {
expect(action).toBeNull();
});
});
describe('episode label helpers', () => {
it('normalizes only positive integers', () => {
expect(getPositiveInteger(1)).toBe(1);
expect(getPositiveInteger(0)).toBeNull();
expect(getPositiveInteger(1.5)).toBeNull();
expect(getPositiveInteger(Number.NaN)).toBeNull();
});
it('pads single digit episode label parts', () => {
expect(padEpisodePart(1)).toBe('01');
expect(padEpisodePart(10)).toBe('10');
});
});
@@ -116,7 +116,9 @@ function createQuickStartAction(
};
}
function getOrderedEpisodes(request: SeriesQuickStartRequest): OrderedEpisode[] {
function getOrderedEpisodes(
request: SeriesQuickStartRequest
): OrderedEpisode[] {
const orderedEpisodes: OrderedEpisode[] = [];
Object.entries(request.seasons)
@@ -186,10 +188,10 @@ function getEpisodeLabel(episode: XtreamSerieEpisode): string {
return title ? `${episodeCode} · ${title}` : episodeCode;
}
function getPositiveInteger(value: number): number | null {
export function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
function padEpisodePart(value: number): string {
export function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
@@ -191,7 +191,10 @@ describe('StalkerCollectionDetailComponent', () => {
})
.overrideComponent(StalkerCollectionDetailComponent, {
remove: {
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
imports: [
ContentHeroComponent,
StalkerInlineDetailComponent,
],
},
add: {
imports: [
@@ -230,7 +233,9 @@ describe('StalkerCollectionDetailComponent', () => {
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod');
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith(
expect.objectContaining({
id: '1507',
@@ -351,6 +356,39 @@ describe('StalkerCollectionDetailComponent', () => {
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
});
it('does not load VOD playback position when the playlist id is missing', async () => {
const playlistsService = TestBed.inject(PlaylistsService) as {
getPlaylistById: jest.Mock;
};
playlistsService.getPlaylistById.mockReturnValue(
of({
...playlist,
_id: '',
})
);
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'movie',
categoryId: 'vod',
stalkerItem: {
id: '1702',
title: 'Collection Movie Without Playlist',
category_id: 'vod',
cmd: '/media/file_1702.mpg',
},
})
);
await settleDetail(fixture);
expect(playbackPositions.getPlaybackPosition).not.toHaveBeenCalled();
expect(fixture.componentInstance.selectedVodPlaybackPosition()).toBe(
null
);
});
});
function buildCollectionItem(
@@ -14,6 +14,7 @@ import { TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import {
buildStalkerStateItem,
createLogger,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -45,6 +46,7 @@ import {
} from 'shared-interfaces';
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
@@ -114,6 +116,7 @@ export class StalkerCollectionDetailComponent {
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerCollectionDetail');
private readonly originalState = this.captureStoreState();
private readonly favoritesRefresh = createRefreshTrigger();
@@ -145,7 +148,16 @@ export class StalkerCollectionDetailComponent {
);
private initRequestId = 0;
private lastInlineSaveTime = 0;
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start collection VOD playback',
});
constructor() {
effect(() => {
@@ -224,50 +236,19 @@ export class StalkerCollectionDetailComponent {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.selectedVodPosition.set(position);
this.vodPlayback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
this.vodPlayback.closeInlinePlayer();
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
this.vodPlayback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
this.vodPlayback.handleExternalFallbackRequest(request);
}
private async prepareDetail(
@@ -304,7 +285,9 @@ export class StalkerCollectionDetailComponent {
);
this.detailCategoryOverride.set(detailMode.category);
this.stalkerStore.setSelectedContentType(detailMode.selectedContentType);
this.stalkerStore.setSelectedContentType(
detailMode.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.resolveSelectedCategory(item, stalkerItem, detailMode)
);
@@ -379,11 +362,8 @@ export class StalkerCollectionDetailComponent {
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem
): StalkerCollectionDetailMode {
const hasEmbeddedSeries = Array.isArray(
(stalkerItem as { series?: unknown[] }).series
)
? (stalkerItem as { series?: unknown[] }).series!.length > 0
: false;
const series = (stalkerItem as { series?: unknown[] }).series;
const hasEmbeddedSeries = Array.isArray(series) && series.length > 0;
const isVodSeries = isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
@@ -420,7 +400,9 @@ export class StalkerCollectionDetailComponent {
return 'vod';
}
return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType);
return (
categoryId ?? toStalkerCategoryId(detailMode.selectedContentType)
);
}
private syncSelectedVodFavorite(): void {
@@ -447,58 +429,24 @@ export class StalkerCollectionDetailComponent {
thumbnail?: string,
startTime?: number
): Promise<void> {
try {
const playback =
startTime === undefined
? await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail
)
: await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
);
this.lastInlineSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail)
: this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
if (!Number.isFinite(vodId)) {
this.selectedVodPosition.set(null);
return;
}
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
this.selectedVodPosition.set(position ?? null);
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
@@ -52,6 +52,7 @@ import {
isStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
interface StalkerFilter {
key: string;
@@ -136,7 +137,16 @@ export class StalkerSearchComponent {
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
private lastInlineSaveTime = 0;
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start search VOD playback',
});
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
@@ -341,50 +351,19 @@ export class StalkerSearchComponent {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.selectedVodPosition.set(position);
this.vodPlayback.handleInlineTimeUpdate(event);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
this.vodPlayback.closeInlinePlayer();
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
this.vodPlayback.showCopyNotification();
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
this.vodPlayback.handleExternalFallbackRequest(request);
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
@@ -454,59 +433,24 @@ export class StalkerSearchComponent {
thumbnail?: string,
startTime?: number
): Promise<void> {
try {
const playback =
startTime === undefined
? await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail
)
: await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
);
this.lastInlineSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start search VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
await this.vodPlayback.startVodPlayback(() =>
startTime === undefined
? this.stalkerStore.resolveVodPlayback(cmd, title, thumbnail)
: this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
)
);
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
if (!playlistId || !Number.isFinite(vodId)) {
this.selectedVodPosition.set(null);
return;
}
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
this.selectedVodPosition.set(position ?? null);
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
@@ -30,8 +30,10 @@ import {
SERIES_QUICK_START_ACTION_KIND,
SeriesQuickStartAction,
createLogger,
getPositiveInteger,
getSeriesQuickStartAction,
getStalkerReturnToState,
padEpisodePart,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
@@ -156,14 +158,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
this.logger.debug('Loading positions for series', {
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
});
if (!isNaN(normalizedSeriesId)) {
void this.loadSeriesPositions(
playlist._id,
@@ -578,8 +577,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
@@ -654,7 +652,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const posterUrl = episodeInfo?.movie_image;
const seasonNum = Number(episode.season || 1);
const episodeNum = episode.episode_num || 1;
const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series';
const seriesTitle =
item.info?.name || this.displayItem()?.info?.name || 'Series';
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
2,
'0'
@@ -686,10 +685,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
playlistId: string,
seriesXtreamId: number
): Promise<void> {
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
@@ -774,7 +774,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return `S${padEpisodePart(seasonNumber)}E01`;
}
private updateEpisodePlaybackPosition(position: PlaybackPositionData): void {
private updateEpisodePlaybackPosition(
position: PlaybackPositionData
): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
@@ -835,11 +837,3 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return Math.abs(hash);
}
}
function getPositiveInteger(value: number): number | null {
return Number.isInteger(value) && value > 0 ? value : null;
}
function padEpisodePart(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
@@ -0,0 +1,131 @@
import type { WritableSignal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalPlaybackPositions,
PortalPlayer,
} from '@iptvnator/portal/shared/util';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
} from 'shared-interfaces';
interface StalkerVodPlaybackControllerConfig {
inlinePlayback: WritableSignal<ResolvedPortalPlayback | null>;
selectedVodPosition: WritableSignal<PlaybackPositionData | null>;
playbackPositions: PortalPlaybackPositions;
portalPlayer: PortalPlayer;
snackBar: MatSnackBar;
translateService: TranslateService;
logger: Logger;
playbackErrorLogMessage: string;
}
export class StalkerVodPlaybackController {
private lastInlineSaveTime = 0;
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
async startVodPlayback(
resolvePlayback: () => Promise<ResolvedPortalPlayback>
): Promise<void> {
try {
const playback = await resolvePlayback();
this.lastInlineSaveTime = 0;
if (this.config.portalPlayer.isEmbeddedPlayer()) {
this.config.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.config.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.config.logger.error(
this.config.playbackErrorLogMessage,
error
);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.config.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.config.translateService.instant(
'PORTALS.PLAYBACK_ERROR'
);
this.config.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
if (!playlistId || !Number.isFinite(vodId)) {
this.config.selectedVodPosition.set(null);
return;
}
const position =
await this.config.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
this.config.selectedVodPosition.set(position ?? null);
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.config.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.config.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.config.selectedVodPosition.set(position);
}
closeInlinePlayer(): void {
this.config.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.config.snackBar.open(
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.config.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
}
}