Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-stalker-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/stalker/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "portal-stalker-feature",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/stalker/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:stalker", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/stalker/feature/jest.config.ts",
"tsConfig": "libs/portal/stalker/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
export * from './lib/stalker-collection-detail.component';
export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
export * from './lib/stalker-series-view/stalker-series-view.component';
export * from './lib/stalker-workspace-route-session.service';
@@ -0,0 +1,25 @@
@if (contentType() === 'vod') {
@if ((selectedItem()?.series?.length ?? 0) > 0) {
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
} @else if (isSeriesDetail()) {
<app-stalker-series-view />
} @else if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
@@ -0,0 +1,379 @@
import {
Component,
OnDestroy,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
getStalkerReturnToState,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { DownloadsService, PlaylistsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
@Component({
selector: 'app-stalker-catalog-detail',
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
templateUrl: './stalker-catalog-detail.component.html',
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly catalog = inject(StalkerCatalogFacadeService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('StalkerCatalogDetail');
private readonly favoritesRefresh = createRefreshTrigger();
readonly contentType = this.catalog.contentType;
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
() =>
(this.catalog.selectedItem() as StalkerSelectedVodItem | null) ??
null
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
private lastInlineSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly isSeriesDetail = computed(() => {
const item = this.selectedItem();
return Boolean(
item &&
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
);
});
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
const item = this.selectedItem();
if (!item || this.contentType() !== 'vod' || this.isSeriesDetail()) {
return null;
}
return createStalkerVodItem(
item as unknown as StalkerVodDetails,
this.catalog.playlist()?.id ?? ''
);
});
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.catalog.playlist()?.id,
() => this.favoritesRefresh.refreshVersion()
);
readonly isSelectedVodFavorite = computed<boolean>(() =>
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
constructor() {
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (
!item ||
!playlistId ||
this.contentType() !== 'vod' ||
this.isSeriesDetail()
) {
this.selectedVodPosition.set(null);
return;
}
void this.loadSelectedVodPosition(playlistId, Number(item.id));
});
effect(() => {
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
this.closeInlinePlayer();
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const currentItem = this.selectedItem();
if (
data.contentType !== 'vod' ||
data.playlistId !== this.catalog.playlist()?.id ||
data.contentXtreamId !== Number(currentItem?.id)
) {
return;
}
this.selectedVodPosition.set(data);
}
);
}
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) =>
this.catalog.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.catalog.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
},
});
}
onVodBack(): void {
const returnTo = getStalkerReturnToState(window.history.state);
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
if (returnTo) {
void this.router.navigateByUrl(returnTo);
}
}
handleInlineTimeUpdate(event: {
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);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') {
return;
}
const playlist = this.catalog.playlist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) {
return;
}
let cmdToUse = item.cmd;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId =
await this.catalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await this.catalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) {
return;
}
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
if (Number.isNaN(vodId)) {
this.selectedVodPosition.set(null);
return;
}
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
this.selectedVodPosition.set(position ?? null);
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
try {
const playback = await this.catalog.resolveVodPlayback(
cmd,
title,
thumbnail,
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 inline 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,
});
}
}
}
@@ -0,0 +1,131 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from 'shared-interfaces';
import { StalkerCatalogFacadeService } from './stalker-catalog-facade.service';
describe('StalkerCatalogFacadeService', () => {
const playlist = {
_id: 'playlist-1',
title: 'Demo Stalker',
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
macAddress: '00:1A:79:00:00:01',
userAgent: 'DemoAgent',
referrer: 'http://demo.example',
origin: 'http://demo.example',
};
const unsubscribe = jest.fn();
let playbackUpdateHandler:
| ((data: PlaybackPositionData) => void)
| undefined;
let playbackPositions: {
savePlaybackPosition: jest.Mock<Promise<void>, [string, PlaybackPositionData]>;
getPlaybackPosition: jest.Mock<Promise<PlaybackPositionData | null>, [string, number, 'vod' | 'episode']>;
getSeriesPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string, number]>;
getRecentPlaybackPositions?: jest.Mock;
getAllPlaybackPositions: jest.Mock<Promise<PlaybackPositionData[]>, [string]>;
clearPlaybackPosition: jest.Mock<Promise<void>, [string, number, 'vod' | 'episode']>;
};
beforeEach(() => {
playbackUpdateHandler = undefined;
unsubscribe.mockReset();
playbackPositions = {
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
getPlaybackPosition: jest.fn().mockResolvedValue(null),
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
};
(window as Window & { electron?: typeof window.electron }).electron = {
...(window.electron ?? {}),
onPlaybackPositionUpdate: jest.fn(
(handler: (data: PlaybackPositionData) => void) => {
playbackUpdateHandler = handler;
return unsubscribe;
}
),
};
TestBed.configureTestingModule({
providers: [
StalkerCatalogFacadeService,
{
provide: StalkerStore,
useValue: {
selectedContentType: signal<'vod' | 'series' | 'itv'>('vod'),
limit: signal(14),
page: signal(0),
getSelectedCategory: signal(null),
getPaginatedContent: signal([]),
selectedItem: signal(null),
getTotalPages: signal(0),
isPaginatedContentLoading: signal(false),
currentPlaylist: signal(playlist),
getSelectedCategoryName: jest.fn(() => null),
setSelectedCategory: jest.fn(),
clearSelectedItem: jest.fn(),
setPage: jest.fn(),
setLimit: jest.fn(),
setSelectedItem: jest.fn(),
createLinkToPlayVod: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
fetchMovieFileId: jest.fn(),
fetchLinkToPlay: jest.fn(),
resolveVodPlayback: jest.fn(),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: playbackPositions,
},
],
});
});
it('persists matching external playback updates for the current playlist', async () => {
TestBed.inject(StalkerCatalogFacadeService);
await Promise.resolve();
expect(playbackUpdateHandler).toBeDefined();
playbackUpdateHandler?.({
playlistId: playlist._id,
contentXtreamId: 17359,
contentType: 'vod',
positionSeconds: 42,
durationSeconds: 5400,
});
await Promise.resolve();
expect(playbackPositions.savePlaybackPosition).toHaveBeenCalledWith(
playlist._id,
expect.objectContaining({
playlistId: playlist._id,
contentXtreamId: 17359,
contentType: 'vod',
positionSeconds: 42,
durationSeconds: 5400,
})
);
});
it('ignores external playback updates for other playlists', async () => {
TestBed.inject(StalkerCatalogFacadeService);
await Promise.resolve();
playbackUpdateHandler?.({
playlistId: 'playlist-2',
contentXtreamId: 17359,
contentType: 'vod',
positionSeconds: 42,
durationSeconds: 5400,
});
await Promise.resolve();
expect(playbackPositions.savePlaybackPosition).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,324 @@
import {
DestroyRef,
Injectable,
Provider,
computed,
effect,
inject,
signal,
} from '@angular/core';
import {
buildStalkerSelectedVodItem,
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import {
PortalCatalogItemProgress,
PortalCatalogPlaylistMeta,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
PORTAL_PLAYBACK_POSITIONS,
StalkerPortalCatalogFacade,
} from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from 'shared-interfaces';
function calculateProgress(position: PlaybackPositionData | undefined): number {
if (!position || !position.durationSeconds) {
return 0;
}
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
@Injectable()
export class StalkerCatalogFacadeService
implements
StalkerPortalCatalogFacade<
Record<string, unknown>,
StalkerVodSource,
StalkerVodSource
>
{
private readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly destroyRef = inject(DestroyRef);
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(
new Map()
);
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
private loadedPositionsForPlaylistId: string | null = null;
readonly provider = 'stalker' as const;
readonly pageSizeOptions = [14] as const;
readonly contentType = this.stalkerStore.selectedContentType;
readonly limit = this.stalkerStore.limit;
readonly pageIndex = this.stalkerStore.page;
readonly selectedCategory = this.stalkerStore.getSelectedCategory;
readonly paginatedContent = computed(
() => this.stalkerStore.getPaginatedContent() ?? []
);
readonly selectedItem = this.stalkerStore.selectedItem;
readonly totalPages = this.stalkerStore.getTotalPages;
readonly isPaginatedContentLoading =
this.stalkerStore.isPaginatedContentLoading;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
const fromCategory = String(
category?.['category_name'] ?? category?.['name'] ?? ''
);
if (fromCategory) {
return fromCategory;
}
return this.stalkerStore.getSelectedCategoryName() ?? '';
});
readonly categoryItemCount = computed(() => this.stalkerStore.totalCount());
readonly contentSortMode = computed<PortalCatalogSortMode | null>(
() => null
);
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
const playlist = this.stalkerStore.currentPlaylist();
if (!playlist?._id) {
return null;
}
return {
id: playlist._id,
title: playlist.title ?? 'Stalker Portal',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
};
});
constructor() {
effect(() => {
const playlistId = this.playlist()?.id;
if (!playlistId) {
this.loadedPositionsForPlaylistId = null;
this.stalkerPositions.set(new Map());
this.stalkerSeriesPositions.set(new Map());
return;
}
if (playlistId === this.loadedPositionsForPlaylistId) {
return;
}
this.loadedPositionsForPlaylistId = playlistId;
void this.loadStalkerPositions(playlistId);
});
if (window.electron?.onPlaybackPositionUpdate) {
const unsubscribe = window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (data.playlistId !== this.playlist()?.id) {
return;
}
void this.playbackPositions.savePlaybackPosition(
data.playlistId,
data
);
if (data.contentType === 'vod') {
this.updateVodPlaybackPosition(data);
}
if (
data.contentType === 'episode' &&
data.seriesXtreamId
) {
this.updateSeriesPlaybackPosition(data);
}
}
);
if (typeof unsubscribe === 'function') {
this.destroyRef.onDestroy(unsubscribe);
}
}
}
initialize(categoryId?: string | null): void {
this.clearSelectedItem();
if (categoryId) {
this.stalkerStore.setSelectedCategory(categoryId);
return;
}
this.stalkerStore.setSelectedCategory('*');
}
clearSelectedItem(): void {
this.stalkerStore.clearSelectedItem();
}
setPage(page: number): void {
this.stalkerStore.setPage(page);
}
setLimit(limit: number): void {
this.stalkerStore.setLimit(limit);
}
setContentSortMode(mode: PortalCatalogSortMode): void {
void mode;
// Stalker catalog content is server-paginated and does not support local sort modes.
}
selectItem(item: StalkerVodSource): string[] | null {
const needsSeriesFetch =
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1);
this.stalkerStore.setSelectedItem(
buildStalkerSelectedVodItem(item, needsSeriesFetch)
);
return null;
}
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
const numericId = Number(item.id);
if (Number.isNaN(numericId)) {
return {};
}
const hasSeriesProgress = Boolean(
this.stalkerSeriesPositions().get(numericId)?.length
);
const isSeries =
this.contentType() === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
if (hasSeriesProgress) {
return { hasSeriesProgress: true };
}
if (isSeries) {
return { hasSeriesProgress: false };
}
const progress = calculateProgress(
this.stalkerPositions().get(`vod_${numericId}`)
);
return {
progress,
isWatched: progress >= 90,
};
}
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
): Promise<void> {
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void {
this.stalkerStore.addToFavorites(item, onDone);
}
removeFromFavorites(favoriteId: string, onDone?: () => void): void {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
fetchMovieFileId(itemId: string): Promise<string | null> {
return this.stalkerStore.fetchMovieFileId(itemId);
}
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string
): Promise<string> {
return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd);
}
resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
) {
return this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
);
}
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions =
await this.playbackPositions.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((position) => {
positionsMap.set(
`${position.contentType}_${position.contentXtreamId}`,
position
);
if (position.contentType === 'episode' && position.seriesXtreamId) {
const existing = seriesMap.get(position.seriesXtreamId) ?? [];
existing.push(position);
seriesMap.set(position.seriesXtreamId, existing);
}
});
this.stalkerPositions.set(positionsMap);
this.stalkerSeriesPositions.set(seriesMap);
}
private updateVodPlaybackPosition(position: PlaybackPositionData): void {
const updated = new Map(this.stalkerPositions());
updated.set(`vod_${position.contentXtreamId}`, position);
this.stalkerPositions.set(updated);
}
private updateSeriesPlaybackPosition(position: PlaybackPositionData): void {
if (!position.seriesXtreamId) {
return;
}
const updated = new Map(this.stalkerSeriesPositions());
const positionsForSeries = [
...(updated.get(position.seriesXtreamId) ?? []).filter(
(item) => item.contentXtreamId !== position.contentXtreamId
),
position,
];
updated.set(position.seriesXtreamId, positionsForSeries);
this.stalkerSeriesPositions.set(updated);
}
}
export function provideStalkerCatalogFacade(): Provider[] {
return [
StalkerCatalogFacadeService,
{
provide: PORTAL_CATALOG_FACADE,
useExisting: StalkerCatalogFacadeService,
},
];
}
@@ -0,0 +1,42 @@
<div class="search-panel">
<label class="search-field">
<mat-icon>search</mat-icon>
<input
type="search"
spellcheck="false"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</label>
</div>
<div class="channels-list">
@if (filteredItems().length > 0) {
@for (item of filteredItems(); track item.id) {
<app-channel-list-item
[name]="item.o_name || item.name || ''"
[logo]="item.logo"
[selected]="isSelected(item)"
[epgProgram]="
epgPrograms.get(normalizeStalkerEntityId(item.id)) ?? null
"
[progressPercentage]="
currentProgramsProgress.get(normalizeStalkerEntityId(item.id)) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="isFavorite(item)"
(clicked)="onPlay(item)"
(favoriteToggled)="onFavoriteToggle(item)"
/>
}
} @else {
<div class="empty-search-state">
<div class="empty-icon-wrapper">
<mat-icon>search_off</mat-icon>
</div>
<h3>{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}</h3>
<p>We couldn't find anything matching "{{ searchString() }}"</p>
</div>
}
</div>
@@ -0,0 +1,126 @@
:host {
display: block;
height: 100%;
width: 100%;
overflow: hidden;
container-type: inline-size;
}
.channels-list {
display: flex;
flex-direction: column;
height: calc(100% - 56px);
overflow-y: auto;
overflow-x: hidden;
width: 100%;
padding: 4px 8px 12px;
box-sizing: border-box;
gap: 2px;
}
.search-panel {
padding: 10px 12px;
flex-shrink: 0;
height: 56px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.search-field {
flex: 1;
width: 100%;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(127, 127, 127, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.08);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
box-sizing: border-box;
cursor: text;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--app-selection-border);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent);
}
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
opacity: 0.68;
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.875rem;
}
}
.empty-search-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 32px 24px;
text-align: center;
.empty-icon-wrapper {
width: 64px;
height: 64px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow:
0 8px 24px -8px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
mat-icon {
width: 32px;
height: 32px;
font-size: 32px;
opacity: 0.8;
}
}
h3 {
font-size: 1.05rem;
font-weight: 520;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
p {
font-size: 0.85rem;
margin: 0;
opacity: 0.65;
line-height: 1.4;
max-width: 260px;
}
}
@container (max-width: 350px) {
.channels-list {
padding-inline: 6px;
}
}
@@ -0,0 +1,116 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MockPipe } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from './stalker-collection-channels-list.component';
@Component({
selector: 'app-channel-list-item',
standalone: true,
template: '',
})
class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly epgProgram = input<unknown>(null);
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly favoriteToggled = output<void>();
}
describe('StalkerCollectionChannelsListComponent', () => {
let fixture: ComponentFixture<StalkerCollectionChannelsListComponent>;
let component: StalkerCollectionChannelsListComponent;
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
const stalkerStore = {
bulkItvEpgByChannel,
};
beforeEach(async () => {
bulkItvEpgByChannel.set({});
await TestBed.configureTestingModule({
imports: [StalkerCollectionChannelsListComponent],
providers: [{ provide: StalkerStore, useValue: stalkerStore }],
})
.overrideComponent(StalkerCollectionChannelsListComponent, {
remove: {
imports: [ChannelListItemComponent, TranslatePipe],
},
add: {
imports: [
StubChannelListItemComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerCollectionChannelsListComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('items', [
{
id: '10001',
name: 'Alpha TV',
o_name: 'Alpha TV',
logo: 'alpha.png',
},
{
id: '10002',
name: 'Beta TV',
o_name: 'Beta TV',
logo: 'beta.png',
},
]);
});
afterEach(() => {
fixture?.destroy();
});
it('keeps row previews empty until bulk epg has been loaded', () => {
fixture.detectChanges();
expect(component.epgPrograms.size).toBe(0);
expect(component.currentProgramsProgress.size).toBe(0);
});
it('derives row previews from cached bulk epg', () => {
bulkItvEpgByChannel.set({
'10001': [buildProgram('10001', 'Current Show')],
'10002': [buildProgram('10002', 'Other Show')],
});
fixture.detectChanges();
expect(component.epgPrograms.get('10001')?.title).toBe('Current Show');
expect(component.epgPrograms.get('10002')?.title).toBe('Other Show');
});
});
function buildProgram(channelId: string, title: string): EpgProgram {
const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
const stopTimestamp = startTimestamp + 30 * 60;
return {
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel: channelId,
title,
desc: `${title} description`,
category: null,
startTimestamp,
stopTimestamp,
};
}
@@ -0,0 +1,171 @@
import {
ChangeDetectorRef,
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-collection-channels-list',
imports: [ChannelListItemComponent, FormsModule, MatIconModule, TranslatePipe],
templateUrl: './stalker-collection-channels-list.component.html',
styleUrl: './stalker-collection-channels-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCollectionChannelsListComponent {
readonly items = input<StalkerVodSource[]>([]);
readonly selectedItemId = input<string | number | null>(null);
readonly favoriteIds = input<Map<string | number, boolean>>(new Map());
readonly playClicked = output<StalkerVodSource>();
readonly favoriteToggled = output<StalkerVodSource>();
private readonly stalkerStore = inject(StalkerStore);
private readonly cdr = inject(ChangeDetectorRef);
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
readonly searchString = signal('');
readonly filteredItems = computed(() => {
const search = this.searchString().trim().toLowerCase();
if (!search) {
return this.items();
}
return this.items().filter((item) =>
`${item.o_name ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(search)
);
});
readonly epgPrograms = new Map<string | number, EpgProgram>();
readonly currentProgramsProgress = new Map<string | number, number>();
constructor() {
effect(() => {
const items = this.items();
const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel();
this.syncBulkEpgPreviews(items, bulkProgramsByChannel);
});
}
onPlay(item: StalkerVodSource): void {
this.playClicked.emit(item);
}
onFavoriteToggle(item: StalkerVodSource): void {
this.favoriteToggled.emit(item);
}
isSelected(item: StalkerVodSource): boolean {
return String(this.selectedItemId() ?? '') === normalizeStalkerEntityId(item.id);
}
isFavorite(item: StalkerVodSource): boolean {
return this.favoriteIds().get(normalizeStalkerEntityId(item.id)) ?? false;
}
private syncBulkEpgPreviews(
items: StalkerVodSource[],
bulkProgramsByChannel: Record<string, EpgProgram[]>
): void {
this.epgPrograms.clear();
this.currentProgramsProgress.clear();
if (items.length === 0 || Object.keys(bulkProgramsByChannel).length === 0) {
this.cdr.markForCheck();
return;
}
for (const item of items) {
const channelId = normalizeStalkerEntityId(item.id);
const currentProgram = this.findCurrentProgram(
bulkProgramsByChannel[channelId] ?? []
);
if (!currentProgram) {
continue;
}
this.epgPrograms.set(channelId, currentProgram);
this.updateProgramProgress(channelId, currentProgram);
}
this.cdr.markForCheck();
}
private updateProgramProgress(
channelId: string | number,
program: EpgProgram
): void {
const startMs = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stopMs = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
const nowMs = Date.now();
if (
Number.isFinite(startMs) &&
Number.isFinite(stopMs) &&
nowMs >= startMs &&
nowMs <= stopMs &&
stopMs > startMs
) {
this.currentProgramsProgress.set(
channelId,
((nowMs - startMs) / (stopMs - startMs)) * 100
);
return;
}
this.currentProgramsProgress.delete(channelId);
}
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
const nowMs = Date.now();
return (
programs.find((program) => {
const startMs = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stopMs = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
return nowMs >= startMs && nowMs <= stopMs;
}) ?? null
);
}
private getProgramTimestampMs(
rawDate: string,
timestamp?: number | null
): number {
const parsedTimestamp = Number(timestamp);
if (Number.isFinite(parsedTimestamp) && parsedTimestamp > 0) {
return parsedTimestamp * 1000;
}
const parsedDate = Date.parse(rawDate);
return Number.isFinite(parsedDate) ? parsedDate : Number.POSITIVE_INFINITY;
}
}
@@ -0,0 +1,254 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContentHeroComponent } from 'components';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { Playlist } from 'shared-interfaces';
import { of } from 'rxjs';
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
@Component({
selector: 'app-content-hero',
standalone: true,
template: '',
})
class StubContentHeroComponent {
readonly isLoading = input(false);
}
@Component({
selector: 'app-stalker-inline-detail',
standalone: true,
template: '',
})
class StubStalkerInlineDetailComponent {
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<unknown>(null);
readonly isSeries = input(false);
readonly vodDetailsItem = input<unknown>(null);
readonly isFavorite = input(false);
readonly backClicked = output<void>();
readonly playClicked = output<unknown>();
readonly favoriteToggled = output<unknown>();
}
describe('StalkerCollectionDetailComponent', () => {
let fixture: ComponentFixture<StalkerCollectionDetailComponent>;
let currentPlaylist: ReturnType<typeof signal<Playlist | undefined>>;
let selectedContentType: ReturnType<
typeof signal<'vod' | 'itv' | 'series'>
>;
let selectedCategoryId: ReturnType<typeof signal<string | null>>;
let selectedItem: ReturnType<typeof signal<unknown>>;
let stalkerStore: {
currentPlaylist: typeof currentPlaylist;
selectedContentType: typeof selectedContentType;
selectedCategoryId: typeof selectedCategoryId;
selectedItem: typeof selectedItem;
setCurrentPlaylist: jest.Mock;
setSelectedContentType: jest.Mock;
setSelectedCategory: jest.Mock;
setSelectedItem: jest.Mock;
addToFavorites: jest.Mock;
removeFromFavorites: jest.Mock;
createLinkToPlayVod: jest.Mock;
};
const playlist = {
_id: 'stalker-1',
title: 'Stalker Portal',
portalUrl: 'http://portal.example/portal.php',
macAddress: '00:1A:79:00:00:01',
favorites: [],
} as unknown as Playlist;
beforeEach(async () => {
currentPlaylist = signal<Playlist | undefined>(undefined);
selectedContentType = signal<'vod' | 'itv' | 'series'>('vod');
selectedCategoryId = signal<string | null>(null);
selectedItem = signal<unknown>(null);
stalkerStore = {
currentPlaylist,
selectedContentType,
selectedCategoryId,
selectedItem,
setCurrentPlaylist: jest.fn(async (value: Playlist | undefined) => {
currentPlaylist.set(value);
}),
setSelectedContentType: jest.fn(
(value: 'vod' | 'itv' | 'series') => {
selectedContentType.set(value);
}
),
setSelectedCategory: jest.fn((value: string | null) => {
selectedCategoryId.set(value);
}),
setSelectedItem: jest.fn((value: unknown) => {
selectedItem.set(value);
}),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
createLinkToPlayVod: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [StalkerCollectionDetailComponent],
providers: [
{
provide: StalkerStore,
useValue: stalkerStore,
},
{
provide: PlaylistsService,
useValue: {
getPlaylistById: jest.fn(() => of(playlist)),
getPortalFavorites: jest.fn(() => of([])),
},
},
],
})
.overrideComponent(StalkerCollectionDetailComponent, {
remove: {
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
},
add: {
imports: [
StubContentHeroComponent,
StubStalkerInlineDetailComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerCollectionDetailComponent);
});
afterEach(() => {
fixture?.destroy();
});
it('opens legacy VOD is_series favorites through the lazy VOD-series flow', async () => {
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'series',
categoryId: 'series',
stalkerItem: {
id: '1507',
title: 'Flagged Series',
category_id: 'series',
cmd: '/media/file_1507.mpg',
is_series: '1',
},
})
);
await settleDetail(fixture);
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod');
expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith(
expect.objectContaining({
id: '1507',
is_series: true,
})
);
expect(fixture.componentInstance.inlineDetail()).toEqual(
expect.objectContaining({
categoryId: 'vod',
isSeries: true,
vodDetailsItem: null,
})
);
});
it('keeps regular Stalker series favorites in regular series mode', async () => {
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'series',
categoryId: 'series',
stalkerItem: {
id: '30001',
series_id: '30001',
title: 'Regular Series',
category_id: 'series',
},
})
);
await settleDetail(fixture);
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'series'
);
expect(fixture.componentInstance.inlineDetail()).toEqual(
expect.objectContaining({
categoryId: 'series',
isSeries: false,
vodDetailsItem: null,
})
);
});
it('keeps embedded VOD series favorites in the embedded VOD-series path', async () => {
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'series',
categoryId: 'series',
stalkerItem: {
id: '20001',
title: 'Embedded Series',
category_id: 'series',
cmd: '/media/file_20001.mpg',
series: [1, 2],
},
})
);
await settleDetail(fixture);
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'vod'
);
expect(fixture.componentInstance.inlineDetail()).toEqual(
expect.objectContaining({
categoryId: 'vod',
isSeries: false,
vodDetailsItem: null,
})
);
expect(
fixture.componentInstance.inlineDetail().seriesItem?.series
).toEqual([1, 2]);
});
});
function buildCollectionItem(
overrides: Partial<UnifiedCollectionItem>
): UnifiedCollectionItem {
return {
uid: 'stalker::stalker-1::item-1',
name: 'Item',
contentType: 'movie',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker Portal',
stalkerId: 'item-1',
...overrides,
};
}
async function settleDetail(
fixture: ComponentFixture<StalkerCollectionDetailComponent>
): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
fixture.detectChanges();
}
@@ -0,0 +1,335 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { ContentHeroComponent } from 'components';
import {
buildStalkerStateItem,
toStalkerCategoryId,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import {
buildStalkerSelectedVodItem,
clearStalkerDetailViewState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
createStalkerInlineDetailState,
isSelectedStalkerVodFavorite,
isStalkerSeriesFlag,
StalkerSelectedVodItem,
StalkerStore,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { Playlist, StalkerPortalItem, VodDetailsItem } from 'shared-interfaces';
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
selectedContentType: 'vod' | 'itv' | 'series';
selectedCategoryId: string | null | undefined;
selectedItem: unknown;
}
type StalkerDetailCategory = 'vod' | 'series';
interface StalkerCollectionDetailMode {
category: StalkerDetailCategory;
selectedContentType: StalkerDetailCategory;
hasEmbeddedSeries: boolean;
needsSeriesFetch: boolean;
}
@Component({
selector: 'app-stalker-collection-detail',
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
template: `
@if (inlineDetail().categoryId) {
<app-stalker-inline-detail
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(backClicked)="closeRequested.emit()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
/>
} @else {
<app-content-hero [isLoading]="true" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
}
`,
],
})
export class StalkerCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly closeRequested = output<void>();
private readonly playlistsService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
private readonly originalState = this.captureStoreState();
private readonly favoritesRefresh = createRefreshTrigger();
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly isSelectedVodFavorite = signal(false);
readonly detailCategoryOverride = signal<StalkerDetailCategory | null>(
null
);
readonly inlineDetail = computed(() =>
createStalkerInlineDetailState(
this.itemDetails(),
this.vodDetailsItem(),
this.detailCategoryOverride()
)
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistsService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
private initRequestId = 0;
constructor() {
effect(() => {
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
effect(() => {
const item = this.item();
untracked(() => {
void this.prepareDetail(item);
});
});
}
ngOnDestroy(): void {
void this.stalkerStore.setCurrentPlaylist(
this.originalState.currentPlaylist
);
this.stalkerStore.setSelectedContentType(
this.originalState.selectedContentType
);
this.stalkerStore.setSelectedCategory(
this.originalState.selectedCategoryId
);
this.stalkerStore.setSelectedItem(
this.originalState.selectedItem as never
);
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.stalkerStore.createLinkToPlayVod(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) =>
this.stalkerStore.addToFavorites(
item as StalkerPortalItem,
onDone
),
removeFromFavorites: (favoriteId, onDone) =>
this.stalkerStore.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
private async prepareDetail(
item: UnifiedCollectionItem | null
): Promise<void> {
const requestId = ++this.initRequestId;
if (!item) {
this.clearLocalDetailState();
return;
}
const playlist = await this.loadPlaylist(item.playlistId);
if (requestId !== this.initRequestId) {
return;
}
if (!playlist?.macAddress || !playlist.portalUrl) {
this.clearLocalDetailState();
return;
}
await this.stalkerStore.setCurrentPlaylist(playlist);
if (requestId !== this.initRequestId) {
return;
}
const stalkerItem = this.resolveStalkerItem(item);
const detailMode = this.resolveDetailMode(item, stalkerItem);
const itemDetails = buildStalkerSelectedVodItem(
stalkerItem as never,
detailMode.needsSeriesFetch
);
this.detailCategoryOverride.set(detailMode.category);
this.stalkerStore.setSelectedContentType(detailMode.selectedContentType);
this.stalkerStore.setSelectedCategory(
this.resolveSelectedCategory(item, stalkerItem, detailMode)
);
this.stalkerStore.setSelectedItem(itemDetails);
this.itemDetails.set(itemDetails);
if (
detailMode.selectedContentType === 'vod' &&
!detailMode.hasEmbeddedSeries &&
!detailMode.needsSeriesFetch
) {
const detailViewState = createStalkerDetailViewState(
itemDetails,
playlist._id
);
this.itemDetails.set(detailViewState.itemDetails);
this.vodDetailsItem.set(detailViewState.vodDetailsItem);
this.syncSelectedVodFavorite();
return;
}
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.isSelectedVodFavorite.set(false);
}
private captureStoreState(): StalkerCollectionStateSnapshot {
return {
currentPlaylist:
(this.stalkerStore.currentPlaylist() as Playlist | undefined) ??
undefined,
selectedContentType: this.stalkerStore.selectedContentType(),
selectedCategoryId: this.stalkerStore.selectedCategoryId(),
selectedItem: this.stalkerStore.selectedItem(),
};
}
private async loadPlaylist(playlistId: string): Promise<Playlist | null> {
try {
return (
(await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) ?? null
);
} catch {
return null;
}
}
private resolveStalkerItem(item: UnifiedCollectionItem): StalkerPortalItem {
return buildStalkerStateItem(
item.stalkerItem as StalkerPortalItem | undefined,
{
id:
item.stalkerId ??
item.uid.split('::')[item.uid.split('::').length - 1] ??
item.uid,
title: item.name,
type: item.contentType,
category_id: item.categoryId,
poster_url: item.posterUrl ?? item.logo ?? undefined,
}
) as StalkerPortalItem;
}
private resolveDetailMode(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem
): StalkerCollectionDetailMode {
const hasEmbeddedSeries = Array.isArray(
(stalkerItem as { series?: unknown[] }).series
)
? (stalkerItem as { series?: unknown[] }).series!.length > 0
: false;
const isVodSeries = isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
const isRegularSeries =
item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries;
const selectedContentType: StalkerDetailCategory = isRegularSeries
? 'series'
: 'vod';
return {
category: selectedContentType,
selectedContentType,
hasEmbeddedSeries,
needsSeriesFetch:
selectedContentType === 'vod' &&
!hasEmbeddedSeries &&
isVodSeries,
};
}
private resolveSelectedCategory(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem,
detailMode: StalkerCollectionDetailMode
): string | number {
const categoryId =
item.categoryId ??
(stalkerItem as { category_id?: string | number }).category_id;
if (
detailMode.selectedContentType === 'vod' &&
String(categoryId ?? '').toLowerCase() === 'series'
) {
return 'vod';
}
return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType);
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
}
private clearLocalDetailState(): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails.set(cleared.itemDetails);
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.detailCategoryOverride.set(null);
this.isSelectedVodFavorite.set(false);
}
}
@@ -0,0 +1,48 @@
import {
inject,
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
} from '@iptvnator/portal/shared/ui';
import { routeParamSignal } from '@iptvnator/portal/shared/util';
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
@Component({
selector: 'app-stalker-collection-route',
imports: [
StalkerCollectionDetailComponent,
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
],
template: `
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
>
<ng-template unifiedCollectionDetail let-item let-close="close">
<app-stalker-collection-detail
[item]="item"
(closeRequested)="close()"
/>
</ng-template>
</app-unified-collection-page>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerCollectionRouteComponent {
private readonly route = inject(ActivatedRoute);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('stalker');
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
(value) => value ?? undefined
);
}
@@ -0,0 +1,12 @@
@let isFavorite = isFavorite$ | async;
@if (isFavorite === false) {
<button class="favorite-btn" (click)="addToFavorites()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button class="favorite-btn favorite-btn--active" (click)="removeFromFavorites()">
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@@ -0,0 +1,32 @@
.favorite-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
@@ -0,0 +1,138 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { of } from 'rxjs';
import { FavoritesButtonComponent } from './stalker-favorites-button.component';
describe('FavoritesButtonComponent', () => {
let fixture: ComponentFixture<FavoritesButtonComponent>;
let selectedContentType: ReturnType<typeof signal<'vod' | 'series'>>;
let addToFavorites: jest.Mock;
beforeEach(async () => {
selectedContentType = signal<'vod' | 'series'>('series');
addToFavorites = jest.fn((_item: unknown, onDone?: () => void) => {
onDone?.();
});
await TestBed.configureTestingModule({
imports: [
FavoritesButtonComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: StalkerStore,
useValue: {
currentPlaylist: signal({ _id: 'stalker-1' }),
selectedContentType,
addToFavorites,
removeFromFavorites: jest.fn(),
},
},
{
provide: PlaylistsService,
useValue: {
getPortalFavorites: jest.fn(() => of([])),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(FavoritesButtonComponent);
});
afterEach(() => {
fixture?.destroy();
});
it('preserves VOD-series favorite payload shape from the VOD series view', () => {
selectedContentType.set('vod');
fixture.componentRef.setInput('itemId', '1507');
fixture.componentRef.setInput('item', {
id: '1507',
cmd: '/media/file_1507.mpg',
category_id: '2001',
is_series: '1',
info: {
name: 'VOD Flagged Series',
movie_image: 'poster.jpg',
},
});
fixture.detectChanges();
fixture.componentInstance.addToFavorites();
expect(addToFavorites).toHaveBeenCalledWith(
expect.objectContaining({
id: '1507',
category_id: '2001',
is_series: true,
title: 'VOD Flagged Series',
cover: 'poster.jpg',
}),
expect.any(Function)
);
expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id');
});
it('preserves embedded VOD series arrays in favorite payloads', () => {
selectedContentType.set('vod');
fixture.componentRef.setInput('itemId', '20001');
fixture.componentRef.setInput('item', {
id: '20001',
cmd: '/media/file_20001.mpg',
category_id: '2001',
series: [1, 2],
info: {
name: 'Embedded Series',
movie_image: 'embedded.jpg',
},
});
fixture.detectChanges();
fixture.componentInstance.addToFavorites();
expect(addToFavorites).toHaveBeenCalledWith(
expect.objectContaining({
id: '20001',
category_id: '2001',
series: [1, 2],
title: 'Embedded Series',
cover: 'embedded.jpg',
}),
expect.any(Function)
);
expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id');
});
it('writes regular Stalker series favorites with series_id', () => {
selectedContentType.set('series');
fixture.componentRef.setInput('itemId', '30001');
fixture.componentRef.setInput('item', {
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
movie_image: 'series.jpg',
},
});
fixture.detectChanges();
fixture.componentInstance.addToFavorites();
expect(addToFavorites).toHaveBeenCalledWith(
expect.objectContaining({
series_id: '30001',
category_id: 'series',
title: 'Regular Series',
cover: 'series.jpg',
}),
expect.any(Function)
);
});
});
@@ -0,0 +1,97 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from 'services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
matchesFavoriteById,
normalizeStalkerSeriesFlag,
StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
type FavoriteButtonItem = StalkerSelectedVodItem & {
name?: string;
o_name?: string;
cover?: string;
};
@Component({
selector: 'app-favorites-button',
templateUrl: './stalker-favorites-button.component.html',
styleUrls: ['./stalker-favorites-button.component.scss'],
imports: [AsyncPipe, MatIconModule, TranslateModule],
})
export class FavoritesButtonComponent {
private playlistService = inject(PlaylistsService);
private stalkerStore = inject(StalkerStore);
readonly itemId = input.required<string>();
readonly item = input.required<FavoriteButtonItem>();
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
readonly isFavorite$ = this.favoritesChanged$.pipe(
switchMap(() =>
this.playlistService.getPortalFavorites(
this.stalkerStore.currentPlaylist()?._id
)
),
map((favorites) =>
favorites.some((favorite) =>
matchesFavoriteById(favorite, this.itemId())
)
)
);
removeFromFavorites() {
this.stalkerStore.removeFromFavorites(this.itemId(), () => {
this.favoritesChanged$.next();
});
}
addToFavorites() {
const item = this.item();
const favoriteBase = {
...item,
title: item.info?.name ?? item.name ?? item.o_name,
cover: item.info?.movie_image ?? item.cover,
added_at: new Date().toISOString(),
};
if (this.stalkerStore.selectedContentType() === 'vod') {
this.stalkerStore.addToFavorites(
{
...favoriteBase,
id: item.id,
category_id: item.category_id ?? 'vod',
series: item.series,
is_series: normalizeStalkerSeriesFlag(item.is_series),
},
() => {
this.favoritesChanged$.next();
}
);
return;
}
this.stalkerStore.addToFavorites(
{
...favoriteBase,
series_id: item.id,
category_id: 'series',
},
() => {
this.favoritesChanged$.next();
}
);
}
forceRefresh() {
// Exposed for callers that need manual refresh after external updates.
if (this.stalkerStore.currentPlaylist()?._id) {
this.favoritesChanged$.next();
}
}
}
@@ -0,0 +1,126 @@
import { Route } from '@angular/router';
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
import { provideStalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
type ComponentLoader = NonNullable<Route['loadComponent']>;
const loadDownloadsComponent: ComponentLoader = () =>
import('@iptvnator/portal/downloads/feature').then(
(c) => c.DownloadsComponent
);
const loadStalkerLiveStreamLayoutComponent: ComponentLoader = () =>
import('./stalker-live-stream-layout/stalker-live-stream-layout.component').then(
(c) => c.StalkerLiveStreamLayoutComponent
);
const loadCategoryContentViewComponent: ComponentLoader = () =>
import('@iptvnator/portal/catalog/feature').then(
(c) => c.CategoryContentViewComponent
);
const loadStalkerCollectionRouteComponent: ComponentLoader = () =>
import('./stalker-collection-route.component').then(
(c) => c.StalkerCollectionRouteComponent
);
const loadStalkerSearchComponent: ComponentLoader = () =>
import('./stalker-search/stalker-search.component').then(
(c) => c.StalkerSearchComponent
);
export function createStalkerRoutes(): Route[] {
return [
{
path: 'stalker/:id',
providers: provideStalkerWorkspaceRouteSession(),
children: [
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'vod',
providers: [
...provideStalkerCatalogFacade(),
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: StalkerCatalogDetailComponent,
},
],
children: [
{
path: '',
data: {
api: 'stalker',
contentType: 'vod',
},
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
data: {
api: 'stalker',
contentType: 'vod',
},
loadComponent: loadCategoryContentViewComponent,
},
],
},
{
path: 'itv',
loadComponent: loadStalkerLiveStreamLayoutComponent,
},
{
path: 'series',
providers: [
...provideStalkerCatalogFacade(),
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: StalkerCatalogDetailComponent,
},
],
children: [
{
path: '',
data: {
api: 'stalker',
contentType: 'series',
},
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
data: {
api: 'stalker',
contentType: 'series',
},
loadComponent: loadCategoryContentViewComponent,
},
],
},
{
path: 'favorites',
loadComponent: loadStalkerCollectionRouteComponent,
data: { mode: 'favorites', portalType: 'stalker' },
},
{
path: 'recent',
loadComponent: loadStalkerCollectionRouteComponent,
data: { mode: 'recent', portalType: 'stalker' },
},
{
path: 'search',
loadComponent: loadStalkerSearchComponent,
},
{
path: 'downloads',
loadComponent: loadDownloadsComponent,
},
],
},
];
}
@@ -0,0 +1,24 @@
@if (categoryId() === 'vod') {
@if ((seriesItem()?.series?.length ?? 0) > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="seriesItem()"
(backClicked)="onBackClicked()"
/>
} @else if (isSeries()) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onBackClicked()" />
} @else if (vodDetailsItem()) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem()"
[isFavorite]="isFavorite()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onPlayClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(backClicked)="onBackClicked()"
/>
}
} @else if (categoryId() === 'series') {
<app-stalker-series-view (backClicked)="onBackClicked()" />
}
@@ -0,0 +1,55 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { VodDetailsItem } from 'shared-interfaces';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@Component({
selector: 'app-stalker-inline-detail',
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
templateUrl: './stalker-inline-detail.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class StalkerInlineDetailComponent {
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input<boolean>(false);
readonly backClicked = output<void>();
readonly playClicked = output<VodDetailsItem>();
readonly favoriteToggled = output<{
item: VodDetailsItem;
isFavorite: boolean;
}>();
onBackClicked() {
this.backClicked.emit();
}
onPlayClicked(item: VodDetailsItem) {
this.playClicked.emit(item);
}
onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) {
this.favoriteToggled.emit(event);
}
}
@@ -0,0 +1,106 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@if (stalkerStore.selectedCategoryId()) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header category-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
@if (visibleChannels().length > 0) {
<span class="category-subtitle"
>{{ visibleChannels().length }}
{{
visibleChannels().length === 1 ? 'item' : 'items'
}}</span
>
}
</div>
</div>
<div #scrollContainer class="channels-list">
@if (searchTerm() && visibleChannels().length === 0) {
<div class="empty-search-state">
<app-portal-empty-state
icon="search_off"
[message]="'CHANNELS.NO_SEARCH_RESULTS' | translate"
/>
</div>
} @else {
@for (item of visibleChannels(); track item.id ?? $index) {
<app-channel-list-item
[name]="item.o_name || item.name || ''"
[logo]="item.logo"
[selected]="isSelectedChannel(item)"
[epgProgram]="
epgPreviewPrograms.get(
normalizeStalkerEntityId(item.id)
) ?? null
"
[progressPercentage]="
currentProgramsProgress.get(
normalizeStalkerEntityId(item.id)
) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="
favorites.get(normalizeStalkerEntityId(item.id)) ??
false
"
(clicked)="playChannel(item)"
(favoriteToggled)="toggleFavorite(item)"
/>
}
}
@if (isLoadingMore()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
</div>
</div>
}
<div class="content-container">
@if (!stalkerStore.selectedCategoryId()) {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
} @else if (stalkerStore.selectedItem()) {
@if (streamUrl() && isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl()" />
</div>
}
<div class="epg">
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else {
<app-epg-list
[controlledChannel]="controlledChannel()"
[controlledPrograms]="activeEpgPrograms()"
[controlledArchiveDays]="0"
/>
}
</div>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -0,0 +1,65 @@
@use '../../../../../../ui/styles/portal-layout' as portal;
@use '../../../../../../ui/styles/panel-header' as panel;
@use '../../../../../../ui/styles/portal-sidebar';
@include portal.live-layout;
.category-content-header {
@include panel.standard-panel-header(
$padding: 8px 12px,
$gap: 8px,
$min-height: 48px,
$border-color: var(--app-separator)
);
app-region: drag;
button {
app-region: no-drag;
}
}
.category-meta {
@include panel.standard-panel-meta(8px);
}
.category-title {
@include panel.standard-panel-title(0.94rem);
color: var(--mat-sys-on-surface);
}
.category-subtitle {
@include panel.standard-panel-subtitle(
$font-size: 0.72rem,
$padding: 2px 8px,
$color: var(--mat-sys-on-surface-variant),
$background: var(--mat-sys-surface-container)
);
}
.channels-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.loading-indicator {
display: flex;
justify-content: center;
padding: 16px;
}
.load-more-epg {
display: flex;
justify-content: center;
padding: 8px 0;
}
@container (max-width: 350px) {
.channels-list {
padding-inline: 6px;
}
}
@@ -0,0 +1,327 @@
import { Component, Directive, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { MockPipe } from 'ng-mocks';
import { of } from 'rxjs';
import { PlaylistsService } from 'services';
import { EpgProgram } from 'shared-interfaces';
import { WebPlayerViewComponent } from 'shared-portals';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
@Component({
selector: 'app-channel-list-item',
standalone: true,
template: '',
})
class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly epgProgram = input<unknown>(null);
readonly progressPercentage = input(0);
readonly showFavoriteButton = input(false);
readonly showProgramInfoButton = input(false);
readonly isFavorite = input(false);
readonly clicked = output<void>();
readonly favoriteToggled = output<void>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
class StubWebPlayerViewComponent {
readonly streamUrl = input('');
}
@Component({
selector: 'app-epg-list',
standalone: true,
template: '',
})
class StubEpgListComponent {
readonly controlledChannel = input<unknown>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
}
@Component({
selector: 'app-portal-empty-state',
standalone: true,
template: '',
})
class StubPortalEmptyStateComponent {
readonly icon = input('');
readonly message = input('');
}
@Directive({
selector: '[appResizable]',
standalone: true,
})
class StubResizableDirective {}
describe('StalkerLiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
let component: StalkerLiveStreamLayoutComponent;
let fetchChannelEpg: jest.Mock;
let ensureBulkItvEpg: jest.Mock;
let resolveItvPlayback: jest.Mock;
const playlist = signal({
_id: 'playlist-1',
title: 'Demo Stalker',
});
const selectedCategoryId = signal<string | null>('1001');
const searchPhrase = signal('');
const itvChannels = signal([
{
id: '10001',
cmd: 'ffrt4://itv/10001',
name: 'Alpha TV',
o_name: 'Alpha TV',
logo: 'alpha.png',
},
{
id: '10002',
cmd: 'ffrt4://itv/10002',
name: 'Beta TV',
o_name: 'Beta TV',
logo: 'beta.png',
},
]);
const selectedItvId = signal<string | undefined>('10001');
const selectedItem = signal<{
id: string;
cmd: string;
name: string;
o_name: string;
logo: string;
} | null>(itvChannels()[0]);
const selectedItvEpgPrograms = signal<EpgProgram[]>([]);
const bulkItvEpgByChannel = signal<Record<string, EpgProgram[]>>({});
const bulkItvEpgLoaded = signal(false);
const bulkItvEpgPlaylistId = signal<string | null>(null);
const bulkItvEpgPeriodHours = signal<number | null>(null);
const isLoadingBulkItvEpg = signal(false);
const stalkerStore = {
getSelectedCategoryName: signal('News'),
itvChannels,
searchPhrase,
hasMoreChannels: signal(false),
selectedItvId,
currentPlaylist: playlist,
selectedItvEpgPrograms,
bulkItvEpgByChannel,
bulkItvEpgLoaded,
bulkItvEpgPlaylistId,
bulkItvEpgPeriodHours,
isLoadingBulkItvEpg,
selectedCategoryId,
selectedItem,
selectedContentType: signal<'itv' | 'vod' | 'series'>('itv'),
page: signal(0),
setItvChannels: jest.fn(),
setPage: jest.fn(),
setSelectedItem: jest.fn((item) => {
selectedItem.set(item);
selectedItvId.set(String(item.id));
selectedItvEpgPrograms.set(
bulkItvEpgByChannel()[String(item.id)] ?? []
);
}),
resolveItvPlayback: jest.fn(),
fetchChannelEpg: jest.fn(),
ensureBulkItvEpg: jest.fn(),
clearBulkItvEpgCache: jest.fn(() => {
bulkItvEpgByChannel.set({});
bulkItvEpgLoaded.set(false);
bulkItvEpgPlaylistId.set(null);
bulkItvEpgPeriodHours.set(null);
selectedItvEpgPrograms.set([]);
}),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
};
const playlistService = {
getPortalFavorites: jest.fn(() => of([])),
};
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
};
beforeEach(async () => {
fetchChannelEpg = stalkerStore.fetchChannelEpg;
ensureBulkItvEpg = stalkerStore.ensureBulkItvEpg;
resolveItvPlayback = stalkerStore.resolveItvPlayback;
selectedCategoryId.set('1001');
searchPhrase.set('');
selectedItvId.set('10001');
selectedItem.set(itvChannels()[0]);
selectedItvEpgPrograms.set([]);
bulkItvEpgByChannel.set({});
bulkItvEpgLoaded.set(false);
bulkItvEpgPlaylistId.set(null);
bulkItvEpgPeriodHours.set(null);
isLoadingBulkItvEpg.set(false);
resolveItvPlayback.mockReset();
resolveItvPlayback.mockResolvedValue({
streamUrl: 'https://example.com/alpha.m3u8',
});
fetchChannelEpg.mockReset();
fetchChannelEpg.mockResolvedValue([]);
ensureBulkItvEpg.mockReset();
ensureBulkItvEpg.mockImplementation(async () => {
const bulkPrograms = {
'10001': [buildProgram('10001', 'Current Show')],
'10002': [buildProgram('10002', 'Next Channel Show')],
};
bulkItvEpgByChannel.set(bulkPrograms);
bulkItvEpgLoaded.set(true);
bulkItvEpgPlaylistId.set('playlist-1');
bulkItvEpgPeriodHours.set(168);
selectedItvEpgPrograms.set(
bulkPrograms[selectedItvId() ?? ''] ?? []
);
});
stalkerStore.setItvChannels.mockClear();
stalkerStore.setPage.mockClear();
stalkerStore.setSelectedItem.mockClear();
stalkerStore.clearBulkItvEpgCache.mockClear();
await TestBed.configureTestingModule({
imports: [StalkerLiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{ provide: StalkerStore, useValue: stalkerStore },
{ provide: PlaylistsService, useValue: playlistService },
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
{
provide: TranslateService,
useValue: {
instant: jest.fn((value: string) => value),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
remove: {
imports: [
ChannelListItemComponent,
EpgListComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
},
add: {
imports: [
StubChannelListItemComponent,
StubEpgListComponent,
StubPortalEmptyStateComponent,
StubResizableDirective,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
StubWebPlayerViewComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture?.destroy();
});
it('renders the controlled epg list and removes the load-more button', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('app-epg-list')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('.load-more-epg')
).toBeNull();
});
it('keeps row previews empty before bulk epg is loaded', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(component.epgPreviewPrograms.size).toBe(0);
expect(fetchChannelEpg).not.toHaveBeenCalled();
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
});
it('derives row previews from cached bulk epg after first playback', async () => {
fixture.detectChanges();
await component.playChannel(itvChannels()[0]);
await fixture.whenStable();
fixture.detectChanges();
expect(component.epgPreviewPrograms.get('10001')?.title).toBe(
'Current Show'
);
expect(component.epgPreviewPrograms.get('10002')?.title).toBe(
'Next Channel Show'
);
expect(fetchChannelEpg).not.toHaveBeenCalled();
});
it('ensures bulk EPG only once across channel switches for the same playlist', async () => {
fixture.detectChanges();
await component.playChannel(itvChannels()[0]);
await fixture.whenStable();
await component.playChannel(itvChannels()[1]);
await fixture.whenStable();
expect(ensureBulkItvEpg).toHaveBeenCalledTimes(1);
expect(ensureBulkItvEpg).toHaveBeenCalledWith(168);
});
});
function buildProgram(channelId: string, title: string): EpgProgram {
const startTimestamp = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
const stopTimestamp = startTimestamp + 30 * 60;
return {
start: new Date(startTimestamp * 1000).toISOString(),
stop: new Date(stopTimestamp * 1000).toISOString(),
channel: channelId,
title,
desc: `${title} description`,
category: null,
startTimestamp,
stopTimestamp,
};
}
@@ -0,0 +1,624 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
ElementRef,
inject,
OnDestroy,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
ResizableDirective,
} from 'components';
import { PlaylistsService } from 'services';
import {
Channel,
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from 'shared-portals';
import {
PORTAL_PLAYER,
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
StalkerFavoriteItem,
StalkerItvChannel,
StalkerStore,
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: ['./stalker-live-stream-layout.component.scss'],
imports: [
ChannelListItemComponent,
EpgListComponent,
MatProgressSpinnerModule,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playlistService = inject(PlaylistsService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerLiveStream');
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
/** Channels */
readonly itvChannels = this.stalkerStore.itvChannels;
readonly searchTerm = computed(() =>
this.stalkerStore.searchPhrase().trim().toLowerCase()
);
readonly visibleChannels = computed(() => {
const channels = this.itvChannels();
const term = this.searchTerm();
if (!term) {
return channels;
}
return channels.filter((item) =>
`${item.o_name ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(term)
);
});
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
readonly isLoadingMore = signal(false);
readonly selectedChannelId = this.stalkerStore.selectedItvId;
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
/** Player */
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly streamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
/** EPG */
readonly fallbackEpgPrograms = signal<EpgProgram[]>([]);
readonly isLoadingFallbackEpg = signal(false);
readonly activeEpgPrograms = computed(() => {
const bulkPrograms = this.stalkerStore.selectedItvEpgPrograms();
return bulkPrograms.length > 0
? bulkPrograms
: this.fallbackEpgPrograms();
});
readonly currentProgram = computed(() =>
this.findCurrentProgram(this.activeEpgPrograms())
);
readonly controlledChannel = computed<Channel | null>(() => {
const selectedType = this.stalkerStore.selectedContentType();
const selectedItem = this.stalkerStore.selectedItem();
if (selectedType !== 'itv' || !selectedItem?.id) {
return null;
}
const channelId = normalizeStalkerEntityId(selectedItem.id);
const channelName = selectedItem.o_name || selectedItem.name || '';
return {
id: channelId,
name: channelName,
url: this.streamUrl() || String(selectedItem.cmd ?? ''),
group: { title: '' },
tvg: {
id: channelId,
name: channelName,
url: '',
logo: selectedItem.logo ?? '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
});
readonly isLoadingEpg = computed(
() =>
this.stalkerStore.isLoadingBulkItvEpg() ||
this.isLoadingFallbackEpg()
);
/** Channel list EPG preview */
readonly epgPreviewPrograms = new Map<string | number, EpgProgram>();
readonly currentProgramsProgress = new Map<string | number, number>();
private readonly cdr = inject(ChangeDetectorRef);
/** Favorites */
readonly favorites = new Map<string | number, boolean>();
/** Scroll */
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
private scrollListener: (() => void) | null = null;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private epgLoadRequestId = 0;
private lastPlaylistId: string | null | undefined = undefined;
constructor() {
// Load favorites for current playlist
this.playlistService
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
.pipe(takeUntilDestroyed())
.subscribe((favs) => {
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(
normalizeStalkerEntityId(fav.id),
true
);
}
});
});
// Reset channels/page on category change
effect(() => {
this.stalkerStore.selectedCategoryId();
this.stalkerStore.setItvChannels([]);
this.stalkerStore.setPage(0);
this.clearEpgPreviewMaps();
this.epgLoadRequestId += 1;
this.fallbackEpgPrograms.set([]);
this.isLoadingFallbackEpg.set(false);
});
// Reset loading state when channels load and keep preview data in sync with bulk EPG.
effect(() => {
const channels = this.visibleChannels();
if (channels.length > 0) {
this.isLoadingMore.set(false);
if (!this.searchTerm()) {
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
}
}
this.syncBulkEpgPreviews(channels);
});
effect(() => {
const playlistId = this.stalkerStore.currentPlaylist()?._id ?? null;
if (playlistId === this.lastPlaylistId) {
return;
}
this.lastPlaylistId = playlistId;
this.epgLoadRequestId += 1;
this.fallbackEpgPrograms.set([]);
this.isLoadingFallbackEpg.set(false);
this.stalkerStore.clearBulkItvEpgCache();
});
// Setup scroll listener when container becomes available
effect(() => {
const container = this.scrollContainer();
if (container) {
this.setupScrollListener();
}
});
effect(() => {
if (!window.electron?.updateRemoteControlStatus) {
return;
}
const selectedItem = this.stalkerStore.selectedItem();
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.visibleChannels();
if (selectedType !== 'itv' || !selectedItem?.id) {
window.electron.updateRemoteControlStatus({
portal: 'stalker',
isLiveView: false,
supportsVolume: false,
});
return;
}
const currentIndex = channels.findIndex(
(item) => Number(item.id) === Number(selectedItem.id)
);
const currentProgram = this.currentProgram();
window.electron.updateRemoteControlStatus({
portal: 'stalker',
isLiveView: true,
channelName: selectedItem.o_name || selectedItem.name,
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
epgTitle: currentProgram?.title,
epgStart: currentProgram?.start,
epgEnd: currentProgram?.stop,
supportsVolume: false,
});
});
if (window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
}
if (window.electron?.onRemoteControlCommand) {
const unsubscribe = window.electron.onRemoteControlCommand(
(command) => {
this.handleRemoteControlCommand(command);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteCommand = unsubscribe;
}
}
}
ngOnDestroy() {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.removeScrollListener();
}
isSelectedChannel(item: StalkerItvChannel): boolean {
return (
this.selectedChannelId() === this.normalizeStalkerEntityId(item.id)
);
}
async playChannel(item: StalkerItvChannel) {
this.stalkerStore.setSelectedItem(item);
try {
const playback = await this.stalkerStore.resolveItvPlayback(item);
void this.loadEpgForChannel(item);
if (this.usesEmbeddedPlayer()) {
this.activePlayback.set(playback);
} else {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Playback failed', error);
const errorMessage =
error?.message === 'nothing_to_play'
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, { duration: 3000 });
}
}
toggleFavorite(item: StalkerItvChannel) {
const itemId = normalizeStalkerEntityId(item.id);
if (this.favorites.has(itemId)) {
this.stalkerStore.removeFromFavorites(itemId);
this.favorites.delete(itemId);
} else {
this.stalkerStore.addToFavorites({
...item,
category_id: 'itv',
title: item.o_name || item.name,
cover: item.logo,
added_at: new Date().toISOString(),
});
this.favorites.set(itemId, true);
}
}
loadMore() {
if (this.isLoadingMore() || !this.hasMoreItems()) return;
this.isLoadingMore.set(true);
const nextPage = this.stalkerStore.page() + 1;
this.stalkerStore.setPage(nextPage);
}
private async loadEpgForChannel(item: StalkerItvChannel) {
const requestId = ++this.epgLoadRequestId;
const normalizedChannelId = normalizeStalkerEntityId(item.id);
const playlistId = this.stalkerStore.currentPlaylist()?._id ?? null;
const shouldEnsureBulk =
!this.stalkerStore.bulkItvEpgLoaded() ||
this.stalkerStore.bulkItvEpgPlaylistId() !== playlistId ||
this.stalkerStore.bulkItvEpgPeriodHours() !== 168;
this.fallbackEpgPrograms.set([]);
this.isLoadingFallbackEpg.set(false);
try {
if (shouldEnsureBulk) {
await this.stalkerStore.ensureBulkItvEpg(168);
if (
!this.isCurrentEpgRequest(
requestId,
normalizedChannelId
)
) {
return;
}
}
if (this.stalkerStore.selectedItvEpgPrograms().length > 0) {
return;
}
this.isLoadingFallbackEpg.set(true);
const fallbackItems = await this.stalkerStore.fetchChannelEpg(
item.id
);
if (
!this.isCurrentEpgRequest(requestId, normalizedChannelId)
) {
return;
}
this.fallbackEpgPrograms.set(
fallbackItems.map((epgItem) =>
this.toProgram(epgItem, normalizedChannelId)
)
);
} catch (error) {
this.logger.warn('Failed to load Stalker live EPG', error);
if (
this.isCurrentEpgRequest(requestId, normalizedChannelId)
) {
this.fallbackEpgPrograms.set([]);
}
} finally {
if (
this.isCurrentEpgRequest(requestId, normalizedChannelId)
) {
this.isLoadingFallbackEpg.set(false);
}
}
}
private clearEpgPreviewMaps() {
this.epgPreviewPrograms.clear();
this.currentProgramsProgress.clear();
}
private syncBulkEpgPreviews(channels: StalkerItvChannel[]): void {
this.clearEpgPreviewMaps();
const bulkProgramsByChannel = this.stalkerStore.bulkItvEpgByChannel();
if (
channels.length === 0 ||
Object.keys(bulkProgramsByChannel).length === 0
) {
this.cdr.markForCheck();
return;
}
for (const channel of channels) {
const channelId = normalizeStalkerEntityId(channel.id);
const currentProgram = this.findCurrentProgram(
bulkProgramsByChannel[channelId] ?? []
);
if (!currentProgram) {
continue;
}
this.epgPreviewPrograms.set(channelId, currentProgram);
this.updateProgramProgress(channelId, currentProgram);
}
this.cdr.markForCheck();
}
private updateProgramProgress(
channelId: string | number,
program: EpgProgram
): void {
const startMs = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stopMs = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
const nowMs = Date.now();
if (
Number.isFinite(startMs) &&
Number.isFinite(stopMs) &&
nowMs >= startMs &&
nowMs <= stopMs &&
stopMs > startMs
) {
this.currentProgramsProgress.set(
channelId,
((nowMs - startMs) / (stopMs - startMs)) * 100
);
return;
}
this.currentProgramsProgress.delete(channelId);
}
private setupScrollListener() {
this.removeScrollListener();
const container = this.scrollContainer()?.nativeElement;
if (!container) return;
const onScroll = () => {
if (this.isLoadingMore() || !this.hasMoreItems()) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const scrollThreshold = 150;
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
this.loadMore();
}
};
container.addEventListener('scroll', onScroll, { passive: true });
this.scrollListener = () =>
container.removeEventListener('scroll', onScroll);
}
private checkIfNeedsMoreContent() {
const container = this.scrollContainer()?.nativeElement;
if (!container) return;
if (this.isLoadingMore() || !this.hasMoreItems()) return;
const { scrollHeight, clientHeight } = container;
if (scrollHeight <= clientHeight) {
this.loadMore();
}
}
private removeScrollListener() {
if (this.scrollListener) {
this.scrollListener();
this.scrollListener = null;
}
}
private toProgram(
item: EpgItem,
channelId: string | number
): EpgProgram {
return {
start: item.start,
stop: item.stop || item.end,
channel: String(channelId),
title: item.title,
desc: item.description || null,
category: null,
startTimestamp: this.toTimestamp(item.start_timestamp, item.start),
stopTimestamp: this.toTimestamp(
item.stop_timestamp,
item.stop || item.end
),
};
}
private toTimestamp(
rawTimestamp: string | number | null | undefined,
rawDate: string
): number | null {
const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10);
if (Number.isFinite(timestamp) && timestamp > 0) {
return timestamp;
}
const parsedDate = Date.parse(rawDate);
return Number.isFinite(parsedDate)
? Math.floor(parsedDate / 1000)
: null;
}
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null {
const now = Date.now();
return (
programs.find((program) => {
const start = this.getProgramTimestampMs(
program.start,
program.startTimestamp
);
const stop = this.getProgramTimestampMs(
program.stop,
program.stopTimestamp
);
return start !== null && stop !== null && now >= start && now < stop;
}) ?? null
);
}
private getProgramTimestampMs(
rawDate: string,
rawTimestamp?: number | null
): number | null {
if (Number.isFinite(rawTimestamp) && Number(rawTimestamp) > 0) {
return Number(rawTimestamp) * 1000;
}
const parsedDate = Date.parse(rawDate);
return Number.isFinite(parsedDate) ? parsedDate : null;
}
private isCurrentEpgRequest(
requestId: number,
normalizedChannelId: string
): boolean {
return (
requestId === this.epgLoadRequestId &&
this.selectedChannelId() === normalizedChannelId
);
}
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.stalkerStore.selectedItem();
if (!activeItem?.id) {
return;
}
const channels = this.visibleChannels();
const nextItem = getAdjacentChannelItem(
channels,
activeItem.id,
direction,
(item) => item.id
);
if (!nextItem) {
return;
}
void this.playChannel(nextItem);
}
private handleRemoteControlCommand(command: {
type:
| 'channel-select-number'
| 'volume-up'
| 'volume-down'
| 'volume-toggle-mute';
number?: number;
}): void {
if (command.type !== 'channel-select-number' || !command.number) {
return;
}
const channel = getChannelItemByNumber(
this.visibleChannels(),
command.number
);
if (!channel) {
return;
}
void this.playChannel(channel);
}
}
@@ -0,0 +1,50 @@
<app-search-layout
[searchTerm]="searchTerm()"
[resultsCount]="resultsCount"
[isLoading]="searchResultsResource.isLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
[showDetails]="showingDetails"
(searchTermChange)="updateSearchTerm($event)"
>
@if (showingDetails) {
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
<!-- Filters (single selection mode for stalker) -->
<ng-container filters>
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</ng-container>
<!-- Results -->
<ng-container results>
<div class="results-grid">
@for (item of searchResultsResource.value(); track item.id) {
<app-content-card
[title]="item.o_name || item.name"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
}
</div>
</ng-container>
</app-search-layout>
@@ -0,0 +1,20 @@
@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid;
:host {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
width: 100%;
}
.details-view {
height: 100%;
width: 100%;
overflow-y: auto;
}
.results-grid {
@include grid.content-grid(140px, 20px);
padding-bottom: 1rem;
}
@@ -0,0 +1,362 @@
import {
Component,
computed,
effect,
inject,
resource,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
VodDetailsItem,
} from 'shared-interfaces';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import { StalkerContentTypes } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerSelectedVodItem,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import {
buildStalkerSelectedVodItem,
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
isStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
interface StalkerFilter {
key: string;
label: string;
translationKey: string;
}
interface StalkerSearchResponse {
js?: {
data?: StalkerVodSource[];
};
message?: string;
status?: number;
}
@Component({
selector: 'app-stalker-search',
imports: [
ContentCardComponent,
FormsModule,
MatCheckboxModule,
SearchLayoutComponent,
StalkerInlineDetailComponent,
TranslatePipe,
],
templateUrl: './stalker-search.component.html',
styleUrl: './stalker-search.component.scss',
})
export class StalkerSearchComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly logger = createLogger('StalkerSearch');
readonly filters = signal({
series: false,
vod: true,
});
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly filterConfig: StalkerFilter[] = [
{
key: 'vod',
label: 'Movies',
translationKey: 'PORTALS.SIDEBAR.MOVIES',
},
{
key: 'series',
label: 'Series',
translationKey: 'PORTALS.SIDEBAR.SERIES',
},
];
readonly searchTerm = signal('');
readonly routeSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
(value) => (value ?? '').trim()
);
private readonly currentPlaylist = computed(() => {
const playlist = this.playlistContext.activePlaylist();
return playlist?.macAddress ? playlist : null;
});
readonly selectedFilterType = signal('vod');
private readonly favoritesRefresh = createRefreshTrigger();
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly searchResultsResource = resource({
params: () => ({
contentType: this.selectedFilterType(),
search: this.searchTerm(),
action: StalkerPortalActions.GetOrderedList,
}),
loader: async ({ params }) => {
if (params.search.length < 3) {
return [];
}
const playlist = this.currentPlaylist();
if (!playlist) return [];
const { portalUrl, macAddress } = playlist;
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
try {
const result = await this.stalkerSession.ensureToken(
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist).stalkerSerialNumber;
} catch (error) {
this.logger.error('Failed to get stalker token', error);
}
}
const response =
await this.dataService.sendIpcEvent<StalkerSearchResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: params.contentType,
search: params.search,
max_page_items: 100,
},
token,
serialNumber,
}
);
if (response) {
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
);
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
});
readonly isSelectedVodFavorite = signal<boolean>(false);
constructor() {
effect(() => {
const routeTerm = this.routeSearchTerm();
if (routeTerm !== this.searchTerm()) {
this.searchTerm.set(routeTerm);
}
});
effect(() => {
// Re-evaluate favorite state whenever favorites resource changes.
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
}
/** Check if showing item details */
get showingDetails(): boolean {
return this.inlineDetail().categoryId !== null;
}
/** Get results count for layout */
get resultsCount(): number {
return this.searchResultsResource.value()?.length ?? 0;
}
createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) {
this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
updateSearchTerm(term: string) {
this.searchTerm.set(term);
}
updateFilter(key: string, value: boolean) {
if (value) {
// Single selection mode - set clicked filter, disable others
this.selectedFilterType.set(key);
this.filters.update((f) => {
const newFilters: Record<string, boolean> = {};
Object.keys(f).forEach((k) => {
newFilters[k] = k === key;
});
return newFilters as typeof f;
});
}
}
selectItem(item: StalkerVodSource) {
const hasEmbeddedSeries = item.series?.length > 0;
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
!hasEmbeddedSeries &&
isStalkerSeriesFlag(item.is_series);
this.itemDetails = buildStalkerSelectedVodItem(item, needsSeriesFetch);
this.stalkerStore.setSelectedItem(this.itemDetails);
switch (this.selectedFilterType()) {
case 'vod':
this.stalkerStore.setSelectedContentType('vod');
if (!hasEmbeddedSeries && !needsSeriesFetch) {
const detailViewState = createStalkerDetailViewState(
this.itemDetails,
this.currentPlaylist()?._id ?? ''
);
this.itemDetails = detailViewState.itemDetails;
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
} else {
const cleared = clearStalkerDetailViewState();
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
break;
case 'series':
this.stalkerStore.setSelectedContentType('series');
break;
default:
break;
}
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
onVodBack(): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, onDone);
}
private syncSelectedVodFavorite(): void {
const item = this.vodDetailsItem;
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
item,
this.portalFavorites.value() ?? []
)
);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem,
this.selectedFilterType() === 'series' ? 'series' : 'vod'
);
}
private processItemUrls(
item: StalkerVodSource,
portalUrl: string
): StalkerVodSource {
const processed = { ...item };
if (processed.screenshot_uri) {
processed.screenshot_uri = this.makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
private makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
try {
const url = new URL(baseUrl);
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
}
@@ -0,0 +1,92 @@
@if (displayItem(); as serial) {
<app-content-hero
[title]="serial.info.name"
[description]="serial.info.description"
[posterUrl]="serial.info.movie_image"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Rating -->
<ng-container hero-tags>
@if (serial.info.releasedate) {
<span class="details__tag">{{
serial.info.releasedate
}}</span>
}
@if (serial.info.genre) {
<span class="details__tag">{{
serial.info.genre
}}</span>
}
@if (serial.info.rating_imdb) {
<span class="details__tag details__tag--rating">
⭐ {{ serial.info.rating_imdb }}
</span>
}
@if (serial.info.rating_kinopoisk) {
<span class="details__tag">
KP {{ serial.info.rating_kinopoisk }}
</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (serial.info.actors) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.ACTORS' | translate
}}</span>
<span class="value">{{ serial.info.actors }}</span>
</div>
}
@if (serial.info.director) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ serial.info.director }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<app-favorites-button
[itemId]="serial.id"
[item]="serial"
/>
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
<!-- Seasons & Episodes -->
@if (mappedSeasons()) {
<app-season-container
[seasons]="mappedSeasons()"
[seriesId]="toSeriesId(serial.id)"
[playlistId]="stalkerStore.currentPlaylist()?._id ?? ''"
[seriesTitle]="serial.info?.name"
[playbackPositions]="episodePlaybackPositions()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
isCurrentSeasonLoading(seasonContainer.selectedSeason))
"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
#seasonContainer
/>
}
</app-content-hero>
}
@@ -0,0 +1,685 @@
import {
Component,
OnDestroy,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import {
ContentHeroComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
} from 'components';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from 'shared-interfaces';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
getStalkerReturnToState,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
isVodSeriesItem,
mapRegularSeriesEpisodes,
mapRegularSeriesSeasons,
mapVodSeriesEpisodes,
mapVodSeriesSeasonsToVm,
StalkerMappedEpisode,
StalkerSeriesSeasonVm,
VodSeriesSeasonVm,
normalizeStalkerVodDetailsItem,
StalkerSelectedVodItem,
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService } from 'services';
/**
* Component for displaying series/episodes for Stalker portal content.
* Supports three modes:
* 1. Regular series (type=series): Fetches seasons from API via serialSeasonsResource
* 2. VOD with embedded series (vclub): Uses the series array from the vodWithSeries input
* 3. VOD series (Ministra is_series=1): Fetches seasons/episodes using movie_id and season_id
*/
@Component({
selector: 'app-stalker-series-view',
templateUrl: './stalker-series-view.component.html',
styleUrls: ['../styles/detail-view.scss'],
imports: [
FavoritesButtonComponent,
ContentHeroComponent,
PortalInlinePlayerComponent,
TranslatePipe,
SeasonContainerComponent,
],
})
export class StalkerSeriesViewComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly backClicked = output<void>();
private readonly logger = createLogger('StalkerSeriesView');
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
/**
* Optional input for VOD items with embedded series array (vclub mode)
* When provided, uses this instead of fetching seasons from API
*/
readonly vodWithSeries = input<StalkerVodSource | null>(null);
readonly selectedItem = this.stalkerStore.selectedItem;
/**
* Track VOD series seasons with their loaded episodes
*/
readonly vodSeriesSeasons = signal<VodSeriesSeasonVm[]>([]);
/**
* Indicates if this is a VOD series item (Ministra is_series=1)
* Note: is_series can be true, 1, or "1" depending on the source
*/
readonly isVodSeries = computed(() => {
return (
this.stalkerStore.selectedContentType() === 'vod' &&
isVodSeriesItem(this.displayItem())
);
});
/**
* Loading state for VOD series seasons
*/
readonly isVodSeriesSeasonsLoading =
this.stalkerStore.isVodSeriesSeasonsLoading;
/**
* Loading state for regular series seasons
*/
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
constructor() {
// Effect to load VOD series seasons when a VOD series item is selected
effect(() => {
if (this.isVodSeries()) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
} else {
this.vodSeriesSeasons.set([]);
}
});
// Effect to load playback positions for Stalker series
effect(() => {
const item = this.displayItem();
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,
}
);
if (!isNaN(normalizedSeriesId)) {
void this.loadSeriesPositions(
playlist._id,
normalizedSeriesId
);
}
} else {
this.episodePlaybackPositions.set(new Map());
}
});
effect(() => {
const session = this.externalPlayback.activeSession();
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesId = item ? this.toSeriesId(item.id) : 0;
if (
!session?.contentInfo ||
!playlistId ||
!seriesId ||
session.contentInfo.contentType !== 'episode' ||
session.contentInfo.playlistId !== playlistId ||
session.contentInfo.seriesXtreamId !== seriesId
) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'launching') {
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'opened' || session.status === 'playing') {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
}
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const playlistId =
this.stalkerStore.currentPlaylist()?._id;
const item = this.displayItem();
const seriesId = item ? this.toSeriesId(item.id) : 0;
if (
data.contentType !== 'episode' ||
data.playlistId !== playlistId ||
data.seriesXtreamId !== seriesId
) {
return;
}
this.updateEpisodePlaybackPosition(data);
}
);
}
}
/**
* For VOD with embedded series, we create a single "season" with the episodes
* For regular series, we use the API-fetched seasons
*/
readonly regularSeasons = computed<StalkerSeriesSeasonVm[]>(() =>
mapRegularSeriesSeasons(
this.vodWithSeries(),
this.stalkerStore.getSerialSeasonsResource()
)
);
/**
* Get the item to display details for (either vodWithSeries or selectedItem from store)
*/
readonly displayItem = computed<StalkerSelectedVodItem | null>(() => {
const item = this.vodWithSeries() || this.selectedItem();
return item ? normalizeStalkerVodDetailsItem(item) : null;
});
/**
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
*/
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
() => {
if (this.isVodSeries()) {
return mapVodSeriesEpisodes(
this.vodSeriesSeasons(),
this.displayItem()?.info?.movie_image
);
}
return mapRegularSeriesEpisodes(
this.regularSeasons(),
this.displayItem()?.info?.movie_image
);
}
);
/**
* Handles season selection from the container.
* For VOD Series, triggers lazy loading of episodes.
*/
onSeasonSelected(seasonKey: string) {
if (!this.isVodSeries()) return;
const seasons = this.vodSeriesSeasons();
const season = seasons.find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
if (season && season.episodes.length === 0) {
this.loadEpisodesForSeason(season);
}
}
/**
* Loads episodes for a specific VOD season
*/
async loadEpisodesForSeason(season: VodSeriesSeasonVm) {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
if (index === -1) return;
const updatedSeasons = [...seasons];
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
this.vodSeriesSeasons.set(updatedSeasons);
try {
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
season.video_id,
season.id
);
// Update with loaded episodes
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
episodes: episodes,
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
} catch (error) {
this.logger.error('Failed to load episodes', error);
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
}
}
/**
* Determines if the current selected season is loading
*/
isCurrentSeasonLoading(seasonKey: string): boolean {
if (!this.isVodSeries()) return false;
const season = this.vodSeriesSeasons().find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
return season?.isLoading ?? false;
}
/**
* Handles episode click from the container
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
const mappedEpisode = episode as StalkerMappedEpisode;
if (mappedEpisode.custom_sid === 'vod-series') {
// It's a VOD series episode (is_series=1 mode)
// Use originalId for playback URL, but use generated id for tracking
this.playVodSeriesEpisode({
originalId: mappedEpisode.originalId, // For constructing playback URL
trackingId: episode.id, // The generated unique ID for position tracking
name: episode.title,
series_number: episode.episode_num,
});
} else {
// Regular series or vclub mode (VOD with embedded series array)
// Use originalCmd for playback, episode.id for tracking
const trackingId = Number(episode.id);
this.playEpisodeClicked(
episode.episode_num,
mappedEpisode.originalCmd,
trackingId
);
}
}
/**
* Play episode - handles regular series and vclub mode
*/
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
const item = this.displayItem();
if (!item) return;
this.logger.debug('playEpisodeClicked', {
episodeNum,
cmd,
trackingId,
seriesId: item.id,
});
const position = trackingId
? this.episodePlaybackPositions().get(trackingId)
: undefined;
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
item.info.name,
item.info.movie_image,
episodeNum,
trackingId,
startTime
);
}
/**
* Play VOD series episode
*/
playVodSeriesEpisode(episode: {
originalId?: string;
trackingId: string;
name: string;
series_number: number;
}) {
const item = this.displayItem();
if (!item) return;
// Use originalId for playback URL (this is what the Stalker API expects)
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
// Use trackingId (generated unique ID) for playback position tracking
const trackingId = Number(episode.trackingId);
this.logger.debug('playVodSeriesEpisode', {
originalId: episode.originalId,
trackingId,
seriesId: item.id,
episodeName,
});
const position = this.episodePlaybackPositions().get(trackingId);
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image,
episode.series_number,
trackingId,
startTime
);
}
goBack() {
const returnTo = getStalkerReturnToState(window.history.state);
this.closeInlinePlayer();
this.backClicked.emit();
this.stalkerStore.clearSelectedItem();
if (returnTo) {
void this.router.navigateByUrl(returnTo);
}
}
toSeriesId(id: string | number): number {
const raw = String(id ?? '').trim();
if (!raw) return 0;
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = 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.updateEpisodePlaybackPosition(position);
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
private async startPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<void> {
try {
const playback = await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
this.lastSaveTime = 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 inline series 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,
});
}
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
async handlePlaybackToggleRequested(
request: SeasonContainerPlaybackToggleRequest
): Promise<void> {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) {
return;
}
if (request.nextPosition) {
await this.playbackPositions.savePlaybackPosition(
playlistId,
request.nextPosition
);
this.updateEpisodePlaybackPosition(request.nextPosition);
return;
}
await this.playbackPositions.clearPlaybackPosition(
playlistId,
request.contentXtreamId,
'episode'
);
this.removeEpisodePlaybackPosition(request.contentXtreamId);
}
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
if (!playlist || !playlist.portalUrl || !playlist.macAddress || !item) {
return;
}
const customSid = (episode as { custom_sid?: string }).custom_sid;
const cmd =
customSid === 'vod-series'
? `/media/file_${(episode as { originalId?: string }).originalId}.mpg`
: ((episode as { originalCmd?: string }).originalCmd ?? '');
let url: string;
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd,
episode.episode_num
);
if (!url) {
this.logger.error('Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
this.logger.error('Error resolving Stalker stream URL', error);
return;
}
const episodeInfo = this.getEpisodeInfo(episode);
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 episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
2,
'0'
)}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: this.getEpisodeDownloadId(episode),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.toSeriesId(item.id),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
private async loadSeriesPositions(
playlistId: string,
seriesXtreamId: number
): Promise<void> {
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
});
this.episodePlaybackPositions.set(positionsMap);
}
private updateEpisodePlaybackPosition(position: PlaybackPositionData): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
}
private removeEpisodePlaybackPosition(contentXtreamId: number): void {
const updated = new Map(this.episodePlaybackPositions());
updated.delete(contentXtreamId);
this.episodePlaybackPositions.set(updated);
}
private getEpisodeInfo(
episode: XtreamSerieEpisode
): { movie_image?: string } | undefined {
if (!episode.info || Array.isArray(episode.info)) {
return undefined;
}
return episode.info as { movie_image?: string };
}
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as { custom_sid?: string }).custom_sid;
if (customSid === 'regular-series') {
const cmd = (episode as { originalCmd?: string }).originalCmd;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
return this.hashString(cmd);
}
return Number(episode.id);
}
if (customSid === 'vod-series') {
const originalId = (episode as { originalId?: string | number })
.originalId;
const numericId = Number(originalId);
return Number.isNaN(numericId)
? this.hashString(String(originalId))
: numericId;
}
const numericId = Number(episode.id);
return Number.isNaN(numericId)
? this.hashString(String(episode.id))
: numericId;
}
private hashString(str: string): number {
let hash = 0;
for (let index = 0; index < str.length; index++) {
const char = str.charCodeAt(index);
hash = (hash << 5) - hash + char;
hash &= hash;
}
return Math.abs(hash);
}
}
@@ -0,0 +1,131 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject, of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service';
const PLAYLIST_ID = 'stalker-1';
const ACTIVE_PLAYLIST: PlaylistMeta = {
_id: PLAYLIST_ID,
filename: 'stalker.m3u',
macAddress: '00:1A:79:12:34:56',
portalUrl: 'http://localhost/stalker_portal/server/load.php',
title: 'Test Stalker',
} as PlaylistMeta;
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function getStalkerSectionFromUrl(url: string): string | null {
const match = url.match(
/^\/workspace\/stalker\/[^/]+\/([^/?]+)(?:\/|$)/
);
return match?.[1] ?? null;
}
describe('StalkerWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
const selectedContentType = signal<'vod' | 'itv' | 'series'>('vod');
const playlistContext = {
activePlaylist,
syncFromUrl: jest.fn(),
};
const stalkerStore = {
resetCategories: jest.fn(),
setSelectedCategory: jest.fn(),
clearSelectedItem: jest.fn(),
setCurrentPlaylist: jest.fn().mockResolvedValue(undefined),
setSelectedContentType: jest.fn((type: 'vod' | 'itv' | 'series') => {
selectedContentType.set(type);
}),
setSearchPhrase: jest.fn(),
};
const playlistsService = {
getPlaylistById: jest.fn(() => of(ACTIVE_PLAYLIST)),
};
const router = {
url: `/workspace/stalker/${PLAYLIST_ID}/vod`,
events: routerEvents.asObservable(),
};
beforeEach(async () => {
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
activePlaylist.set(ACTIVE_PLAYLIST);
selectedContentType.set('vod');
playlistContext.syncFromUrl.mockImplementation((url: string) => ({
inWorkspace: true,
provider: 'stalker',
playlistId: PLAYLIST_ID,
section: getStalkerSectionFromUrl(url) as
| 'favorites'
| 'itv'
| 'recent'
| 'search'
| 'series'
| 'vod'
| null,
}));
stalkerStore.resetCategories.mockClear();
stalkerStore.setSelectedCategory.mockClear();
stalkerStore.clearSelectedItem.mockClear();
stalkerStore.setCurrentPlaylist.mockClear();
stalkerStore.setSelectedContentType.mockClear();
stalkerStore.setSearchPhrase.mockClear();
playlistsService.getPlaylistById.mockClear();
await TestBed.configureTestingModule({
providers: [
StalkerWorkspaceRouteSession,
{
provide: PlaylistContextFacade,
useValue: playlistContext,
},
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: Router,
useValue: router,
},
{
provide: StalkerStore,
useValue: stalkerStore,
},
],
});
});
it('keeps the itv route selection after playlist bootstrap', async () => {
router.url = `/workspace/stalker/${PLAYLIST_ID}/itv`;
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(stalkerStore.resetCategories).toHaveBeenCalled();
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
ACTIVE_PLAYLIST
);
expect(stalkerStore.setSelectedContentType).toHaveBeenCalledWith('itv');
expect(selectedContentType()).toBe('itv');
expect(
stalkerStore.setSelectedContentType.mock.invocationCallOrder[0]
).toBeGreaterThan(
stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0]
);
});
});
@@ -0,0 +1,113 @@
import {
DestroyRef,
ENVIRONMENT_INITIALIZER,
inject,
Injectable,
Provider,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter, firstValueFrom } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
type StalkerContentType = 'vod' | 'series' | 'itv';
@Injectable()
export class StalkerWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly stalkerStore = inject(StalkerStore);
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(null);
constructor() {
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
void this.syncRouteContext();
});
void this.syncRouteContext();
this.destroyRef.onDestroy(() => {
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
});
}
private async syncRouteContext(): Promise<void> {
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
const playlistId =
routeContext.provider === 'stalker'
? routeContext.playlistId
: null;
if (playlistId && this.currentPlaylistId !== playlistId) {
this.currentPlaylistId = playlistId;
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
const playlist =
this.playlistContext.activePlaylist() ??
(await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
));
await this.stalkerStore.setCurrentPlaylist(playlist);
}
this.syncRouteState(routeContext.section);
}
private syncRouteState(section: PortalRailSection | null): void {
if (!section) {
return;
}
const previousSection = this.currentSection();
if (section !== previousSection) {
this.currentSection.set(section);
}
if (section === 'vod' || section === 'series' || section === 'itv') {
this.stalkerStore.setSelectedContentType(
section as StalkerContentType
);
}
if (section === 'itv' && previousSection !== 'itv') {
this.stalkerStore.setSelectedCategory(null);
this.stalkerStore.clearSelectedItem();
this.stalkerStore.setSearchPhrase('');
}
}
}
export function provideStalkerWorkspaceRouteSession(): Provider[] {
return [
StalkerWorkspaceRouteSession,
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => {
inject(StalkerWorkspaceRouteSession);
},
},
];
}
@@ -0,0 +1,590 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
width: 100%;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--tag-bg: rgba(255, 255, 255, 0.1);
--tag-border: rgba(255, 255, 255, 0.2);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
}
// ============================================================================
// Details Section - Content Elements
// ============================================================================
.details {
// Styles for projected content elements
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.play-btn {
--play-btn-bg: var(--play-idle-bg);
--play-btn-fg: var(--play-idle-fg);
--play-btn-border: var(--play-idle-border);
--play-btn-shadow: var(--play-idle-shadow);
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
color: var(--play-btn-fg);
border: 1px solid var(--play-btn-border);
border-radius: 16px;
box-shadow: var(--play-btn-shadow);
cursor: pointer;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
background-position 280ms ease,
box-shadow 260ms ease,
border-color 220ms ease,
color 220ms ease,
filter 220ms ease;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: inherit;
background:
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
opacity: 0.7;
transition: opacity 220ms ease;
z-index: -1;
}
&:hover:not(:disabled) {
transform: translateY(-2px);
background-position: 100% 50%;
box-shadow:
var(--play-btn-shadow),
0 10px 20px rgba(255, 255, 255, 0.16) inset;
}
&:hover:not(:disabled)::before {
opacity: 0.95;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--play-btn-shadow);
}
&:disabled {
cursor: progress;
opacity: 1;
filter: saturate(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
color 220ms ease,
filter 220ms ease;
}
&:hover:not(:disabled) .play-btn__icon {
transform: scale(1.08);
}
&__icon-shell {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--play-icon-shell-bg);
border: 1px solid var(--play-icon-shell-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 6px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
transition:
transform 220ms ease,
background-color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
&__label {
display: inline-flex;
align-items: center;
min-width: 0;
}
&--state-launching,
&--state-stop {
color: var(--play-btn-fg);
}
&--state-launching {
--play-btn-bg: var(--play-launching-bg);
--play-btn-fg: var(--play-launching-fg);
--play-btn-border: var(--play-launching-border);
--play-btn-shadow: var(--play-launching-shadow);
animation: playButtonPulse 1.8s ease-in-out infinite;
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
0 10px 22px rgba(184, 89, 26, 0.2);
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
}
}
&--state-stop {
--play-btn-bg: var(--play-stop-bg);
--play-btn-fg: var(--play-stop-fg);
--play-btn-border: var(--play-stop-border);
--play-btn-shadow: var(--play-stop-shadow);
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
0 12px 24px rgba(145, 38, 53, 0.18);
}
.play-btn__icon {
animation: playIconStop 1.2s ease-in-out infinite;
}
}
}
@keyframes playButtonPulse {
0%,
100% {
box-shadow: var(--play-btn-shadow);
}
50% {
box-shadow:
var(--play-btn-shadow),
0 0 0 6px rgba(255, 190, 92, 0.14);
}
}
@keyframes playIconLaunch {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(18deg) scale(1.1);
}
}
@keyframes playIconStop {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.play-btn,
.play-btn::before,
.play-btn mat-icon,
.play-btn__icon-shell {
animation: none !important;
transition-duration: 1ms !important;
}
}
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
// ============================================================================
// Content Sections - Below the hero fold
// ============================================================================
.content-section {
padding: 20px 0;
width: 100%;
box-sizing: border-box;
&__title {
font-size: 1.2rem;
font-weight: 650;
letter-spacing: -0.02em;
margin: 0 0 20px 0;
color: var(--text-primary);
}
}
// ============================================================================
// Seasons & Episodes - Inside details section
// ============================================================================
.seasons {
margin-top: 24px;
width: 100%;
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.expand-icon {
font-size: 0.75rem;
color: var(--text-muted);
}
}
.episodes {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 0 16px;
button {
margin: 0;
font-weight: 500;
}
}
.episodes-loading,
.seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
}
.no-episodes {
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
}
// Seasons loading when inside details
.details .seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
margin-top: 24px;
}
// Season container when inside details section
.details app-season-container {
display: block;
width: 100%;
margin-top: 24px;
box-sizing: border-box;
}
// ============================================================================
// YouTube Trailer Section - Inside details
// ============================================================================
.youtube-trailer {
margin-top: 24px;
width: 100%;
h3 {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.trailer-wrapper {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
// ============================================================================
// Legacy container support (for gradual migration)
// ============================================================================
.container {
display: contents;
}
.image {
display: none; // Hidden - now using .poster in hero
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.action-buttons {
width: 100%;
// This class is in ContentHeroComponent now, but these rules apply to children?
// No, .play-btn is a child of the PROJECTED content.
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
.youtube-trailer {
margin-top: 16px;
.trailer-wrapper {
max-width: 100%;
border-radius: 4px;
}
}
.seasons {
padding: 16px 0 0;
}
}
// ============================================================================
// Animations
// ============================================================================
.youtube-trailer {
animation: fadeInUp 0.5s ease-out 0.4s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================================================
// Utility - keep .label for backwards compatibility
// ============================================================================
.label {
font-weight: 400;
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: none;
padding: 0;
opacity: 1;
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}