mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
aa739f43e0
commit
7ac2fd66b8
175 files changed
+3118
-1076
No files matched your search
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "epg-data-access",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/epg/data-access/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:data-access"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './lib/epg-progress.service';
|
||||
export * from './lib/epg.service';
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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"]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
combineLatestWith,
|
||||
EMPTY,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from 'rxjs';
|
||||
import { DataService, EpgService, PlaylistsService } from 'services';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import {
|
||||
OPEN_MPV_PLAYER,
|
||||
OPEN_VLC_PLAYER,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"sourceRoot": "libs/playlist/import/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
|
||||
"tags": ["scope:playlist", "domain:playlist-import", "type:feature"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistType } from 'components';
|
||||
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { getFilenameFromUrl } from 'm3u-utils';
|
||||
import { DataService } from 'services';
|
||||
|
||||
+4
-1
@@ -14,7 +14,10 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { STALKER_SERIAL_NUMBER, StalkerSessionService } from 'services';
|
||||
import {
|
||||
STALKER_SERIAL_NUMBER,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './lib/playlist-player-actions';
|
||||
export * from './lib/video-player/video-player.component';
|
||||
@@ -19,18 +19,14 @@ import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
ArtPlayerComponent,
|
||||
AudioPlayerComponent,
|
||||
InfoOverlayComponent,
|
||||
ResizableDirective,
|
||||
} from 'components';
|
||||
import {
|
||||
COMPONENT_OVERLAY_REF,
|
||||
EpgListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
InfoOverlayComponent,
|
||||
MultiEpgContainerComponent,
|
||||
ResizableDirective,
|
||||
SidebarComponent,
|
||||
ToolbarComponent,
|
||||
VjsPlayerComponent,
|
||||
} from 'components';
|
||||
} from '@iptvnator/ui/epg';
|
||||
import {
|
||||
ChannelActions,
|
||||
FavoritesActions,
|
||||
@@ -55,6 +51,15 @@ import {
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
ArtPlayerComponent,
|
||||
AudioPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
SidebarComponent,
|
||||
ToolbarComponent,
|
||||
VjsPlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
|
||||
import { DataService, PlaylistsService, SettingsStore } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
@@ -65,7 +70,6 @@ import {
|
||||
SidebarView,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import { PLAYLIST_PLAYER_ACTIONS } from '../playlist-player-actions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "playlist-shared-ui",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playlist/shared/ui/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:ui"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './lib/add-playlist-menu/add-playlist-menu.component';
|
||||
export * from './lib/filter-sort-menu/filter-sort-menu.component';
|
||||
export * from './lib/playlist-switcher/playlist-switcher.component';
|
||||
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
|
||||
export * from './lib/recent-playlists/recent-playlists.component';
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -22,10 +22,10 @@ import {
|
||||
} from 'm3u-state';
|
||||
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
|
||||
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||
import { DialogService } from 'components';
|
||||
import { DatabaseService, DataService, SortBy, SortService } from 'services';
|
||||
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
|
||||
import { DialogService } from '../confirm-dialog/dialog.service';
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
|
||||
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
|
||||
@@ -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,13 @@
|
||||
{
|
||||
"name": "playlist-shared-util",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playlist/shared/util/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:util"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/playlist-player-actions';
|
||||
File renamed without changes.
@@ -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"]
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"sourceRoot": "libs/portal/catalog/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "type:feature"],
|
||||
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
|
||||
+3
-31
@@ -1,34 +1,6 @@
|
||||
@if (selectedCategory()) {
|
||||
@if (isStalker && selectedStalkerItem()) {
|
||||
@if (contentType() === 'vod') {
|
||||
@if (selectedStalkerItem().series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub or Ministra) -->
|
||||
<app-stalker-series-view [vodWithSeries]="selectedStalkerItem()" />
|
||||
} @else if (selectedStalkerItem().is_series) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<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 />
|
||||
}
|
||||
@if (selectedDetailComponent(); as detailComponentRef) {
|
||||
<ng-container *ngComponentOutlet="detailComponentRef" />
|
||||
} @else {
|
||||
<div class="category-content-layout">
|
||||
<div class="category-content-header">
|
||||
@@ -40,7 +12,7 @@
|
||||
{{ categoryItemSubtitle() }}
|
||||
</div>
|
||||
</div>
|
||||
@if (!isStalker) {
|
||||
@if (canSortContent()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
|
||||
+23
-538
@@ -1,56 +1,22 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import { Component, computed, inject, OnInit } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
StalkerSeriesViewComponent,
|
||||
VodDetailsComponent,
|
||||
} from 'components';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
GridListComponent,
|
||||
PlaylistErrorViewComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
isStalkerPortalCatalogFacade,
|
||||
PortalCatalogFacade,
|
||||
PortalCatalogSortMode,
|
||||
PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
PORTAL_CATALOG_FACADE,
|
||||
StalkerPortalCatalogFacade,
|
||||
PortalCatalogSortMode,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
isSelectedStalkerVodFavorite,
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
StalkerSelectedVodItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PlaylistsService, DownloadsService } from 'services';
|
||||
import {
|
||||
createStalkerVodItem,
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
StalkerVodDetails,
|
||||
VodDetailsItem,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
interface CategoryContentItem {
|
||||
id?: number | string;
|
||||
@@ -62,16 +28,6 @@ interface CategoryContentItem {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DownloadVodData {
|
||||
id?: string | number;
|
||||
has_files?: unknown;
|
||||
info?: {
|
||||
name?: string;
|
||||
movie_image?: string;
|
||||
};
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-content-view',
|
||||
templateUrl: './category-content-view.component.html',
|
||||
@@ -83,52 +39,22 @@ interface DownloadVodData {
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatTooltip,
|
||||
NgComponentOutlet,
|
||||
PlaylistErrorViewComponent,
|
||||
StalkerSeriesViewComponent,
|
||||
TranslatePipe,
|
||||
VodDetailsComponent,
|
||||
],
|
||||
})
|
||||
export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
export class CategoryContentViewComponent implements OnInit {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
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 catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade<
|
||||
CategoryContentItem,
|
||||
CategoryContentItem,
|
||||
CategoryContentItem
|
||||
>;
|
||||
private readonly stalkerCatalog:
|
||||
| StalkerPortalCatalogFacade<
|
||||
CategoryContentItem,
|
||||
CategoryContentItem,
|
||||
CategoryContentItem
|
||||
>
|
||||
| null = isStalkerPortalCatalogFacade(this.catalog)
|
||||
? (this.catalog as StalkerPortalCatalogFacade<
|
||||
CategoryContentItem,
|
||||
CategoryContentItem,
|
||||
CategoryContentItem
|
||||
>)
|
||||
: null;
|
||||
private readonly logger = createLogger('CategoryContentView');
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
|
||||
readonly isStalker = this.catalog.provider === 'stalker';
|
||||
|
||||
/** Unsubscribe function for playback position updates */
|
||||
private unsubscribePositionUpdates: (() => void) | null = null;
|
||||
|
||||
readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT);
|
||||
readonly contentType = this.catalog.contentType;
|
||||
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
|
||||
private lastInlineSaveTime = 0;
|
||||
|
||||
readonly limit = this.catalog.limit;
|
||||
readonly pageIndex = this.catalog.pageIndex;
|
||||
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
|
||||
@@ -136,262 +62,41 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
readonly paginatedContent = this.catalog.paginatedContent;
|
||||
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
|
||||
readonly categoryItemCount = this.catalog.categoryItemCount;
|
||||
readonly selectedItem = this.catalog.selectedItem;
|
||||
readonly totalPages = this.catalog.totalPages;
|
||||
readonly contentSortMode = this.catalog.contentSortMode;
|
||||
readonly isPaginatedContentLoading =
|
||||
this.catalog.isPaginatedContentLoading;
|
||||
readonly categoryItemSubtitle = computed(() => {
|
||||
const itemCount = this.categoryItemCount();
|
||||
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
|
||||
});
|
||||
|
||||
/** Stalker playback positions (key: `${contentType}_${id}`) */
|
||||
private readonly stalkerPositions = signal<
|
||||
Map<string, PlaybackPositionData>
|
||||
>(new Map());
|
||||
/** Stalker series positions (key: seriesId, value: episode positions) */
|
||||
private readonly stalkerSeriesPositions = signal<
|
||||
Map<number, PlaybackPositionData[]>
|
||||
>(new Map());
|
||||
/** Track which playlist we've loaded positions for */
|
||||
private loadedPositionsForPlaylistId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
if (this.isStalker) {
|
||||
effect(() => {
|
||||
const playlistId = this.catalog.playlist()?.id;
|
||||
|
||||
if (
|
||||
playlistId &&
|
||||
playlistId !== this.loadedPositionsForPlaylistId
|
||||
) {
|
||||
this.loadedPositionsForPlaylistId = playlistId;
|
||||
this.loadStalkerPositions(playlistId);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const item = this.selectedItem();
|
||||
const playlistId = this.catalog.playlist()?.id;
|
||||
|
||||
if (item && playlistId && this.contentType() === 'vod') {
|
||||
this.loadSingleVodPosition(playlistId, Number(item.id));
|
||||
}
|
||||
});
|
||||
|
||||
if (window.electron?.onPlaybackPositionUpdate) {
|
||||
this.unsubscribePositionUpdates =
|
||||
window.electron.onPlaybackPositionUpdate(
|
||||
(data: PlaybackPositionData) => {
|
||||
if (
|
||||
data.contentType === 'vod' &&
|
||||
data.contentXtreamId
|
||||
) {
|
||||
const key = `vod_${data.contentXtreamId}`;
|
||||
const updated = new Map(
|
||||
this.stalkerPositions()
|
||||
);
|
||||
updated.set(key, data);
|
||||
this.stalkerPositions.set(updated);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const selectedItemId = this.selectedItem()?.id;
|
||||
void selectedItemId;
|
||||
this.closeInlinePlayer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
readonly contentWithProgress = computed(() => {
|
||||
const items = this.paginatedContent();
|
||||
if (!items) return [];
|
||||
|
||||
if (this.isStalker) {
|
||||
const positions = this.stalkerPositions();
|
||||
const seriesPositions = this.stalkerSeriesPositions();
|
||||
const contentType = this.contentType();
|
||||
|
||||
return items.map((item: CategoryContentItem) => {
|
||||
const id = Number(item.id);
|
||||
const hasSeriesProgress = Boolean(
|
||||
seriesPositions.get(id)?.length
|
||||
);
|
||||
const isSeries =
|
||||
contentType === 'series' ||
|
||||
item.is_series === '1' ||
|
||||
item.is_series === 1;
|
||||
|
||||
if (hasSeriesProgress) {
|
||||
return { ...item, hasSeriesProgress: true };
|
||||
}
|
||||
|
||||
if (isSeries) {
|
||||
return { ...item, hasSeriesProgress: false };
|
||||
}
|
||||
|
||||
const vodKey = `vod_${id}`;
|
||||
const vodPosition = positions.get(vodKey);
|
||||
const vodProgress = this.calculateProgress(vodPosition);
|
||||
return {
|
||||
...item,
|
||||
progress: vodProgress,
|
||||
isWatched: vodProgress >= 90,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return items.map((item: CategoryContentItem) => ({
|
||||
readonly canSortContent = computed(() => this.contentSortMode() !== null);
|
||||
readonly selectedDetailComponent = computed(() =>
|
||||
this.selectedItem() ? this.detailComponent : null
|
||||
);
|
||||
readonly contentWithProgress = computed(() =>
|
||||
(this.paginatedContent() ?? []).map((item: CategoryContentItem) => ({
|
||||
...item,
|
||||
...this.catalog.getItemProgress(item),
|
||||
}));
|
||||
});
|
||||
|
||||
readonly isPaginatedContentLoading =
|
||||
this.catalog.isPaginatedContentLoading;
|
||||
readonly selectedItem = this.catalog.selectedItem;
|
||||
readonly totalPages = this.catalog.totalPages;
|
||||
readonly contentSortMode = this.catalog.contentSortMode;
|
||||
readonly selectedStalkerItem = computed<StalkerSelectedVodItem | null>(
|
||||
() => {
|
||||
if (!this.isStalker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (this.selectedItem() as unknown as StalkerSelectedVodItem | null) ?? null;
|
||||
}
|
||||
}))
|
||||
);
|
||||
|
||||
/** Computed VodDetailsItem for the vod-details component */
|
||||
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
|
||||
const item = this.selectedStalkerItem();
|
||||
if (!item || !this.isStalker) return null;
|
||||
|
||||
return createStalkerVodItem(
|
||||
item as unknown as StalkerVodDetails,
|
||||
this.catalog.playlist()?.id ?? ''
|
||||
);
|
||||
});
|
||||
|
||||
/** Playback position for the selected VOD item (Stalker) */
|
||||
readonly selectedVodPlaybackPosition = computed<number | null>(() => {
|
||||
if (!this.isStalker) return null;
|
||||
const item = this.selectedStalkerItem();
|
||||
if (!item) return null;
|
||||
|
||||
const id = Number(item.id);
|
||||
const vodKey = `vod_${id}`;
|
||||
const position = this.stalkerPositions().get(vodKey);
|
||||
|
||||
return position?.positionSeconds ?? null;
|
||||
});
|
||||
|
||||
readonly portalFavorites = createPortalFavoritesResource(
|
||||
this.playlistService,
|
||||
() => (this.isStalker ? this.catalog.playlist()?.id : undefined),
|
||||
() => this.favoritesRefresh.refreshVersion()
|
||||
);
|
||||
|
||||
readonly isSelectedVodFavorite = computed<boolean>(() =>
|
||||
isSelectedStalkerVodFavorite(
|
||||
this.vodDetailsItem(),
|
||||
this.portalFavorites.value() ?? []
|
||||
)
|
||||
);
|
||||
|
||||
seasons = [];
|
||||
|
||||
setContentSortMode(mode: PortalCatalogSortMode): void {
|
||||
this.catalog.setContentSortMode(mode);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
ngOnInit(): void {
|
||||
const { categoryId } = this.activatedRoute.snapshot.params;
|
||||
this.catalog.initialize(categoryId ?? null);
|
||||
|
||||
if (this.isStalker) {
|
||||
const selectedCategory =
|
||||
(this.selectedCategory() as CategoryContentItem | null) ?? null;
|
||||
this.logger.warn('Stalker category init from route', {
|
||||
routeCategoryId: categoryId ?? null,
|
||||
selectedCategoryId:
|
||||
selectedCategory?.category_id ??
|
||||
selectedCategory?.id ??
|
||||
null,
|
||||
selectedContentType: this.contentType(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all playback positions for a Stalker portal
|
||||
*/
|
||||
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((pos) => {
|
||||
const key = `${pos.contentType}_${pos.contentXtreamId}`;
|
||||
positionsMap.set(key, pos);
|
||||
|
||||
if (pos.contentType === 'episode' && pos.seriesXtreamId) {
|
||||
const existing = seriesMap.get(pos.seriesXtreamId) || [];
|
||||
existing.push(pos);
|
||||
seriesMap.set(pos.seriesXtreamId, existing);
|
||||
}
|
||||
});
|
||||
|
||||
this.stalkerPositions.set(positionsMap);
|
||||
this.stalkerSeriesPositions.set(seriesMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load playback position for a single VOD item
|
||||
*/
|
||||
private async loadSingleVodPosition(
|
||||
playlistId: string,
|
||||
vodId: number
|
||||
): Promise<void> {
|
||||
const position = await this.playbackPositions.getPlaybackPosition(
|
||||
playlistId,
|
||||
vodId,
|
||||
'vod'
|
||||
);
|
||||
|
||||
if (position) {
|
||||
const key = `vod_${vodId}`;
|
||||
const updated = new Map(this.stalkerPositions());
|
||||
updated.set(key, position);
|
||||
this.stalkerPositions.set(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage from position data
|
||||
*/
|
||||
private 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));
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent) {
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.catalog.setPage(event.pageIndex);
|
||||
this.catalog.setLimit(event.pageSize);
|
||||
}
|
||||
|
||||
onItemClick(item: CategoryContentItem) {
|
||||
onItemClick(item: CategoryContentItem): void {
|
||||
const navigation = this.catalog.selectItem(item);
|
||||
if (navigation?.length) {
|
||||
this.router.navigate(navigation, {
|
||||
@@ -399,224 +104,4 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createLinkToPlayVod(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string
|
||||
) {
|
||||
if (!this.stalkerCatalog) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stalkerCatalog.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
|
||||
this.logger.debug('Add to favorites', item);
|
||||
if (this.stalkerCatalog) {
|
||||
this.stalkerCatalog.addToFavorites(item, onDone);
|
||||
return;
|
||||
}
|
||||
|
||||
onDone?.();
|
||||
}
|
||||
|
||||
removeFromFavorites(favoriteId: string, onDone?: () => void) {
|
||||
this.logger.debug('Remove from favorites', favoriteId);
|
||||
if (this.stalkerCatalog) {
|
||||
this.stalkerCatalog.removeFromFavorites(favoriteId, onDone);
|
||||
return;
|
||||
}
|
||||
|
||||
onDone?.();
|
||||
}
|
||||
|
||||
/** Handle play from vod-details component */
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
void this.startStalkerVodPlayback(
|
||||
item.cmd,
|
||||
item.data.info?.name,
|
||||
item.data.info?.movie_image
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle resume from vod-details component */
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.closeInlinePlayer();
|
||||
this.catalog.clearSelectedItem();
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
const key = `vod_${position.contentXtreamId}`;
|
||||
const updated = new Map(this.stalkerPositions());
|
||||
updated.set(key, position);
|
||||
this.stalkerPositions.set(updated);
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
this.inlinePlayback.set(null);
|
||||
this.lastInlineSaveTime = 0;
|
||||
}
|
||||
|
||||
showCopyNotification(): void {
|
||||
this.snackBar.open(
|
||||
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
|
||||
null,
|
||||
{
|
||||
duration: 2000,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Handle download from vod-details component */
|
||||
async onVodDownload(item: VodDetailsItem): Promise<void> {
|
||||
if (item.type !== 'stalker' || !this.stalkerCatalog) 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.stalkerCatalog.fetchMovieFileId(normalizedItemId);
|
||||
if (fileId) {
|
||||
cmdToUse = `/media/file_${fileId}.mpg`;
|
||||
}
|
||||
}
|
||||
|
||||
const url = await this.stalkerCatalog.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 startStalkerVodPlayback(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
thumbnail?: string,
|
||||
startTime?: number
|
||||
): Promise<void> {
|
||||
if (!this.stalkerCatalog) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const playback = await this.stalkerCatalog.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -4,7 +4,8 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerPortalItem, XtreamCategory } from 'shared-interfaces';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService, PlaylistInfoComponent } from 'components';
|
||||
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { DialogService } from 'components';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { selectCurrentPlaylist } from 'm3u-state';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/favorites-context.service';
|
||||
export * from './lib/logger';
|
||||
export * from './lib/portal-catalog-detail';
|
||||
export * from './lib/portal-catalog-facade';
|
||||
export * from './lib/portal-external-playback';
|
||||
export * from './lib/portal-player';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { InjectionToken, Type } from '@angular/core';
|
||||
|
||||
export const PORTAL_CATALOG_DETAIL_COMPONENT =
|
||||
new InjectionToken<Type<unknown> | null>('PORTAL_CATALOG_DETAIL_COMPONENT', {
|
||||
factory: () => null,
|
||||
});
|
||||
@@ -3,5 +3,6 @@ export * from './lib/stores';
|
||||
export * from './lib/stalker-content-types';
|
||||
export * from './lib/stalker-live-playback.utils';
|
||||
export * from './lib/stalker-series.adapters';
|
||||
export * from './lib/stalker-session.service';
|
||||
export * from './lib/stalker-vod.utils';
|
||||
export * from './lib/stalker.store';
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface StalkerCategoryItem {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
} from './stalker-series.adapters';
|
||||
import { StalkerSelectedVodItem } from './models';
|
||||
|
||||
type EpisodeWithMetadata = {
|
||||
custom_sid?: string;
|
||||
id?: string;
|
||||
originalId?: string;
|
||||
originalCmd?: string;
|
||||
};
|
||||
|
||||
describe('stalker-series.adapters', () => {
|
||||
it('detects vod-series flags from heterogeneous payloads', () => {
|
||||
expect(isVodSeriesItem({ is_series: true })).toBe(true);
|
||||
@@ -65,9 +72,10 @@ describe('stalker-series.adapters', () => {
|
||||
);
|
||||
|
||||
expect(mapped['1']).toHaveLength(2);
|
||||
expect(mapped['1'][0].custom_sid).toBe('vod-series');
|
||||
expect((mapped['1'][0] as any).originalId).toBe('api-shared-id');
|
||||
expect(mapped['1'][0].id).not.toBe(mapped['1'][1].id);
|
||||
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
|
||||
expect(firstEpisode.custom_sid).toBe('vod-series');
|
||||
expect(firstEpisode.originalId).toBe('api-shared-id');
|
||||
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
|
||||
});
|
||||
|
||||
it('maps embedded series payload into regular season episodes', () => {
|
||||
@@ -84,7 +92,8 @@ describe('stalker-series.adapters', () => {
|
||||
const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg');
|
||||
expect(regularSeasons[0].series).toEqual([1, 2]);
|
||||
expect(mapped['1']).toHaveLength(2);
|
||||
expect(mapped['1'][0].custom_sid).toBe('regular-series');
|
||||
expect((mapped['1'][0] as any).originalCmd).toBe('/media/file_100.mpg');
|
||||
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
|
||||
expect(firstEpisode.custom_sid).toBe('regular-series');
|
||||
expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg');
|
||||
});
|
||||
});
|
||||
+22
-11
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Playlist, STALKER_REQUEST } from 'shared-interfaces';
|
||||
import { DataService } from './data.service';
|
||||
import { DataService } from 'services';
|
||||
|
||||
/**
|
||||
* SHA1 hash using native Web Crypto API
|
||||
@@ -68,6 +68,10 @@ export interface StalkerProfileResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface StalkerAuthConfirmationResponse {
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to manage Stalker portal session tokens.
|
||||
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
|
||||
@@ -262,11 +266,14 @@ export class StalkerSessionService {
|
||||
|
||||
try {
|
||||
const response: StalkerHandshakeResponse =
|
||||
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (response?.js?.token) {
|
||||
return {
|
||||
@@ -342,13 +349,16 @@ export class StalkerSessionService {
|
||||
|
||||
try {
|
||||
const response: StalkerProfileResponse =
|
||||
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
serialNumber,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -376,15 +386,16 @@ export class StalkerSessionService {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.dataService.sendIpcEvent(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
token,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// do_auth returns { js: true } on success
|
||||
if (response?.js === true) {
|
||||
@@ -571,7 +582,7 @@ export class StalkerSessionService {
|
||||
const serialNumber = playlist.stalkerSerialNumber;
|
||||
|
||||
try {
|
||||
const response = await this.dataService.sendIpcEvent(
|
||||
const response = await this.dataService.sendIpcEvent<T>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import { StalkerFavoriteItem } from './models';
|
||||
import {
|
||||
createStalkerInlineDetailState,
|
||||
createStalkerDetailViewState,
|
||||
@@ -131,7 +132,7 @@ describe('stalker-vod.utils regressions', () => {
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
} as any),
|
||||
} as StalkerFavoriteItem),
|
||||
null
|
||||
);
|
||||
|
||||
|
||||
@@ -4,15 +4,12 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaylistsService,
|
||||
StalkerSessionService,
|
||||
} from 'services';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import { StalkerSessionService } from './stalker-session.service';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
|
||||
describe('StalkerStore API compatibility smoke', () => {
|
||||
let store: any;
|
||||
let store: StalkerStore;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
|
||||
+73
-38
@@ -8,7 +8,7 @@ import {
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService, StalkerSessionService } from 'services';
|
||||
import { DataService } from 'services';
|
||||
import {
|
||||
Playlist,
|
||||
STALKER_REQUEST,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
StalkerItvChannel,
|
||||
StalkerVodSource,
|
||||
} from '../../models';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
|
||||
|
||||
@@ -45,6 +46,39 @@ const initialContentState: StalkerContentState = {
|
||||
itvChannels: [],
|
||||
};
|
||||
|
||||
interface ResourceState<T> {
|
||||
value(): T;
|
||||
isLoading(): boolean;
|
||||
error(): unknown;
|
||||
}
|
||||
|
||||
interface StalkerCategoryResponseItem {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface StalkerCategoryResponse {
|
||||
js?: StalkerCategoryResponseItem[];
|
||||
}
|
||||
|
||||
interface StalkerOrderedListResponse {
|
||||
js?: {
|
||||
data?: StalkerVodSource[];
|
||||
total_items?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface StalkerContentStoreContext {
|
||||
selectedContentType(): 'vod' | 'series' | 'itv';
|
||||
currentPlaylist(): Playlist | undefined;
|
||||
selectedCategoryId(): string | null | undefined;
|
||||
searchPhrase(): string;
|
||||
page(): number;
|
||||
limit(): number;
|
||||
getContentResource: ResourceState<StalkerContentItem[] | undefined>;
|
||||
getCategoryResource: ResourceState<StalkerCategoryItem[]>;
|
||||
}
|
||||
|
||||
export function withStalkerContent() {
|
||||
const logger = createLogger('withStalkerContent');
|
||||
return signalStoreFeature(
|
||||
@@ -55,17 +89,21 @@ export function withStalkerContent() {
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService),
|
||||
translateService = inject(TranslateService)
|
||||
) => ({
|
||||
getCategoryResource: resource({
|
||||
) => {
|
||||
const storeContext =
|
||||
store as unknown as StalkerContentStoreContext;
|
||||
|
||||
return {
|
||||
getCategoryResource: resource({
|
||||
params: () => ({
|
||||
contentType: (store as any).selectedContentType(),
|
||||
contentType: storeContext.selectedContentType(),
|
||||
action: StalkerPortalActions.GetCategories,
|
||||
currentPlaylist: (store as any).currentPlaylist(),
|
||||
currentPlaylist: storeContext.currentPlaylist(),
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
}): Promise<StalkerCategoryItem[]> => {
|
||||
if (params.currentPlaylist === undefined) return;
|
||||
if (!params.currentPlaylist) return [];
|
||||
|
||||
switch (params.contentType) {
|
||||
case 'itv':
|
||||
@@ -96,17 +134,16 @@ export function withStalkerContent() {
|
||||
type: params.contentType,
|
||||
};
|
||||
|
||||
let response: any;
|
||||
let response: StalkerCategoryResponse;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
// Full stalker portal - use authenticated request with retry
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
response = await stalkerSession.makeAuthenticatedRequest<StalkerCategoryResponse>(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
// Simple stalker portal - no auth needed
|
||||
response = await dataService.sendIpcEvent(
|
||||
response = await dataService.sendIpcEvent<StalkerCategoryResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
@@ -166,18 +203,16 @@ export function withStalkerContent() {
|
||||
});
|
||||
return categories;
|
||||
},
|
||||
}),
|
||||
getContentResource: resource({
|
||||
}),
|
||||
getContentResource: resource({
|
||||
params: () => ({
|
||||
contentType: (store as any).selectedContentType(),
|
||||
category: (store as any).selectedCategoryId(),
|
||||
contentType: storeContext.selectedContentType(),
|
||||
category: storeContext.selectedCategoryId(),
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
search: (store as any).searchPhrase(),
|
||||
pageIndex: (store as any).page() + 1,
|
||||
search: storeContext.searchPhrase(),
|
||||
pageIndex: storeContext.page() + 1,
|
||||
availableCategoryCount: (() => {
|
||||
const contentType = (
|
||||
store as any
|
||||
).selectedContentType();
|
||||
const contentType = storeContext.selectedContentType();
|
||||
const categories =
|
||||
contentType === 'vod'
|
||||
? store.vodCategories()
|
||||
@@ -211,7 +246,7 @@ export function withStalkerContent() {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const currentPlaylist = (store as any).currentPlaylist;
|
||||
const currentPlaylist = storeContext.currentPlaylist;
|
||||
|
||||
// Guard: ensure currentPlaylist is available (may not be during deep link init)
|
||||
if (
|
||||
@@ -251,17 +286,16 @@ export function withStalkerContent() {
|
||||
|
||||
// Use makeAuthenticatedRequest for automatic retry on auth failure
|
||||
const playlist = currentPlaylist() as Playlist;
|
||||
let response: any;
|
||||
let response: StalkerOrderedListResponse;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
// Full stalker portal - use authenticated request with retry
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
response = await stalkerSession.makeAuthenticatedRequest<StalkerOrderedListResponse>(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
// Simple stalker portal - no auth needed
|
||||
response = await dataService.sendIpcEvent(
|
||||
response = await dataService.sendIpcEvent<StalkerOrderedListResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
@@ -287,7 +321,7 @@ export function withStalkerContent() {
|
||||
toStalkerContentItem(item, portalUrl)
|
||||
);
|
||||
|
||||
if ((store as any).selectedContentType() === 'itv') {
|
||||
if (storeContext.selectedContentType() === 'itv') {
|
||||
const channels = newItems.map(toStalkerItvChannel);
|
||||
// Check if we're loading the first page or loading more
|
||||
if (params.pageIndex === 1) {
|
||||
@@ -312,20 +346,21 @@ export function withStalkerContent() {
|
||||
|
||||
return newItems;
|
||||
},
|
||||
}),
|
||||
})
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
withComputed((store) => {
|
||||
const storeAny = store as any;
|
||||
const storeContext = store as unknown as StalkerContentStoreContext;
|
||||
return {
|
||||
getTotalPages: computed(() => {
|
||||
return Math.ceil(store.totalCount() / storeAny.limit());
|
||||
return Math.ceil(store.totalCount() / storeContext.limit());
|
||||
}),
|
||||
getSelectedCategoryName: computed(() => {
|
||||
const type = storeAny.selectedContentType();
|
||||
const selectedCategoryId = storeAny.selectedCategoryId();
|
||||
const type = storeContext.selectedContentType();
|
||||
const selectedCategoryId = storeContext.selectedCategoryId();
|
||||
if (!selectedCategoryId) return '';
|
||||
let categories = [];
|
||||
let categories: StalkerCategoryItem[] = [];
|
||||
if (type === 'vod') {
|
||||
categories = store.vodCategories();
|
||||
} else if (type === 'series') {
|
||||
@@ -342,23 +377,23 @@ export function withStalkerContent() {
|
||||
}),
|
||||
/** category content */
|
||||
getPaginatedContent: computed(() =>
|
||||
storeAny.getContentResource.value()
|
||||
storeContext.getContentResource.value()
|
||||
),
|
||||
isPaginatedContentLoading: computed(() =>
|
||||
storeAny.getContentResource.isLoading()
|
||||
storeContext.getContentResource.isLoading()
|
||||
),
|
||||
isPaginatedContentFailed: computed(() =>
|
||||
storeAny.getContentResource.error()
|
||||
storeContext.getContentResource.error()
|
||||
),
|
||||
/** category resource */
|
||||
getCategoryResource: computed(() =>
|
||||
storeAny.getCategoryResource.value()
|
||||
storeContext.getCategoryResource.value()
|
||||
),
|
||||
isCategoryResourceLoading: computed(() =>
|
||||
storeAny.getCategoryResource.isLoading()
|
||||
storeContext.getCategoryResource.isLoading()
|
||||
),
|
||||
isCategoryResourceFailed: computed(() =>
|
||||
storeAny.getCategoryResource.error()
|
||||
storeContext.getCategoryResource.error()
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
+29
-6
@@ -1,6 +1,7 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { signalStoreFeature, withMethods } from '@ngrx/signals';
|
||||
import { DataService, StalkerSessionService } from 'services';
|
||||
import { DataService } from 'services';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import {
|
||||
EpgItem,
|
||||
Playlist,
|
||||
@@ -8,6 +9,25 @@ import {
|
||||
StalkerPortalActions,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
interface EpgStoreContext {
|
||||
currentPlaylist(): Playlist | undefined;
|
||||
}
|
||||
|
||||
interface StalkerEpgEntry {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
descr?: string;
|
||||
time?: string;
|
||||
time_to?: string;
|
||||
ch_id?: string | number;
|
||||
start_timestamp?: string | number;
|
||||
stop_timestamp?: string | number;
|
||||
}
|
||||
|
||||
interface StalkerEpgResponse {
|
||||
js?: StalkerEpgEntry[] | { data?: StalkerEpgEntry[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* EPG concern methods.
|
||||
*/
|
||||
@@ -19,14 +39,14 @@ export function withStalkerEpg() {
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => {
|
||||
const storeAny = store as any;
|
||||
const storeContext = store as unknown as EpgStoreContext;
|
||||
|
||||
return {
|
||||
async fetchChannelEpg(
|
||||
channelId: number | string,
|
||||
size = 10
|
||||
): Promise<EpgItem[]> {
|
||||
const playlist = storeAny.currentPlaylist() as Playlist;
|
||||
const playlist = storeContext.currentPlaylist();
|
||||
if (!playlist) return [];
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
@@ -36,7 +56,7 @@ export function withStalkerEpg() {
|
||||
size: String(size),
|
||||
};
|
||||
|
||||
let response: any;
|
||||
let response: StalkerEpgResponse;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
@@ -44,7 +64,8 @@ export function withStalkerEpg() {
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
response = await dataService.sendIpcEvent(
|
||||
response =
|
||||
await dataService.sendIpcEvent<StalkerEpgResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
@@ -54,7 +75,9 @@ export function withStalkerEpg() {
|
||||
);
|
||||
}
|
||||
|
||||
const epgData = response?.js?.data ?? response?.js ?? [];
|
||||
const epgData = Array.isArray(response?.js)
|
||||
? response.js
|
||||
: response?.js?.data ?? [];
|
||||
const items = Array.isArray(epgData) ? epgData : [];
|
||||
|
||||
return items.map((item) => ({
|
||||
|
||||
+16
-6
@@ -5,7 +5,17 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
|
||||
|
||||
interface FavoritesStoreContext {
|
||||
currentPlaylist(): PlaylistMeta | undefined;
|
||||
selectedContentType(): string;
|
||||
}
|
||||
|
||||
type FavoritePayload = StalkerPortalItem & {
|
||||
stream_id?: string | number;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Favorites concern methods.
|
||||
@@ -20,14 +30,14 @@ export function withStalkerFavorites() {
|
||||
translate = inject(TranslateService),
|
||||
ngrxStore = inject(Store)
|
||||
) => {
|
||||
const storeAny = store as any;
|
||||
const storeContext = store as unknown as FavoritesStoreContext;
|
||||
return {
|
||||
addToFavorites(item: any, onDone?: () => void) {
|
||||
const portalId = storeAny.currentPlaylist()?._id;
|
||||
addToFavorites(item: FavoritePayload, onDone?: () => void) {
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
playlistService
|
||||
.addPortalFavorite(portalId, {
|
||||
...item,
|
||||
category_id: storeAny.selectedContentType(),
|
||||
category_id: storeContext.selectedContentType(),
|
||||
added_at: Date.now(),
|
||||
id: item.stream_id ?? item.id,
|
||||
})
|
||||
@@ -57,7 +67,7 @@ export function withStalkerFavorites() {
|
||||
favoriteId: string,
|
||||
onDone?: () => void
|
||||
) {
|
||||
const portalId = storeAny.currentPlaylist()?._id;
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
playlistService
|
||||
.removeFromPortalFavorites(portalId, favoriteId)
|
||||
.subscribe((updatedPlaylist) => {
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
|
||||
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isCrossOriginStalkerStream,
|
||||
STALKER_MAG_USER_AGENT,
|
||||
} from '../../stalker-live-playback.utils';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerEntityIdAsNumber,
|
||||
|
||||
+3
-2
@@ -7,8 +7,9 @@ import {
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
|
||||
import { DataService, StalkerSessionService } from 'services';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { DataService } from 'services';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
|
||||
/**
|
||||
* Portal/session state and methods.
|
||||
@@ -36,7 +37,7 @@ export function withStalkerPortal() {
|
||||
*/
|
||||
async makeStalkerRequest(
|
||||
playlist: PlaylistMeta,
|
||||
params: Record<string, any>
|
||||
params: Record<string, string | number>
|
||||
) {
|
||||
// Get token if it's a full stalker portal
|
||||
let token: string | undefined;
|
||||
|
||||
+27
-10
@@ -3,9 +3,19 @@ import { signalStoreFeature, withMethods } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
interface RecentStoreContext {
|
||||
currentPlaylist(): PlaylistMeta | undefined;
|
||||
selectedContentType(): string;
|
||||
}
|
||||
|
||||
type RecentlyViewedPayload = StalkerPortalItem & {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recently-viewed concern methods.
|
||||
*/
|
||||
@@ -18,17 +28,24 @@ export function withStalkerRecent() {
|
||||
playlistService = inject(PlaylistsService),
|
||||
ngrxStore = inject(Store)
|
||||
) => {
|
||||
const storeAny = store as any;
|
||||
const storeContext = store as unknown as RecentStoreContext;
|
||||
|
||||
return {
|
||||
addToRecentlyViewed(item: any) {
|
||||
const portalId = storeAny.currentPlaylist()?._id;
|
||||
addToRecentlyViewed(item: RecentlyViewedPayload) {
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
const recentItem = {
|
||||
...item,
|
||||
category_id: storeContext.selectedContentType(),
|
||||
added_at: Date.now(),
|
||||
id: item.id ?? item.stream_id ?? '',
|
||||
title:
|
||||
item.title ??
|
||||
item.name ??
|
||||
item.o_name ??
|
||||
'',
|
||||
};
|
||||
playlistService
|
||||
.addPortalRecentlyViewed(portalId, {
|
||||
...item,
|
||||
category_id: storeAny.selectedContentType(),
|
||||
added_at: Date.now(),
|
||||
})
|
||||
.addPortalRecentlyViewed(portalId, recentItem)
|
||||
.subscribe((updatedPlaylist) => {
|
||||
ngrxStore.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
@@ -45,7 +62,7 @@ export function withStalkerRecent() {
|
||||
itemId: string | number,
|
||||
onComplete?: () => void
|
||||
) {
|
||||
const portalId = storeAny.currentPlaylist()?._id;
|
||||
const portalId = storeContext.currentPlaylist()?._id;
|
||||
playlistService
|
||||
.removeFromPortalRecentlyViewed(portalId, itemId)
|
||||
.subscribe({
|
||||
|
||||
+13
-6
@@ -5,9 +5,15 @@ import {
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { StalkerVodSource } from '../../models';
|
||||
import { StalkerCategoryItem, StalkerVodSource } from '../../models';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
|
||||
interface SelectionStoreContext {
|
||||
vodCategories(): StalkerCategoryItem[];
|
||||
seriesCategories(): StalkerCategoryItem[];
|
||||
itvCategories(): StalkerCategoryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection/pagination/search feature state.
|
||||
*/
|
||||
@@ -93,7 +99,7 @@ export function withStalkerSelection() {
|
||||
},
|
||||
/** getters */
|
||||
getSelectedCategory: computed(() => {
|
||||
const storeAny = store as any;
|
||||
const storeContext = store as unknown as SelectionStoreContext;
|
||||
const categoryId = store.selectedCategoryId();
|
||||
if (!categoryId) {
|
||||
return {
|
||||
@@ -105,15 +111,15 @@ export function withStalkerSelection() {
|
||||
|
||||
// Get categories based on content type
|
||||
const contentType = store.selectedContentType();
|
||||
let categories: any[] = [];
|
||||
let categories: StalkerCategoryItem[] = [];
|
||||
const readCategories = (
|
||||
getterName:
|
||||
| 'vodCategories'
|
||||
| 'seriesCategories'
|
||||
| 'itvCategories'
|
||||
) =>
|
||||
typeof storeAny[getterName] === 'function'
|
||||
? storeAny[getterName]()
|
||||
typeof storeContext[getterName] === 'function'
|
||||
? storeContext[getterName]()
|
||||
: [];
|
||||
if (contentType === 'vod') {
|
||||
categories = readCategories('vodCategories');
|
||||
@@ -125,7 +131,8 @@ export function withStalkerSelection() {
|
||||
|
||||
return (
|
||||
categories.find(
|
||||
(c: any) => String(c.category_id) === String(categoryId)
|
||||
(category) =>
|
||||
String(category.category_id) === String(categoryId)
|
||||
) || {
|
||||
category_id: categoryId,
|
||||
category_name: '',
|
||||
|
||||
+62
-34
@@ -7,12 +7,15 @@ import {
|
||||
withProps,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { DataService, StalkerSessionService } from 'services';
|
||||
import { DataService } from 'services';
|
||||
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
|
||||
import {
|
||||
StalkerSeason,
|
||||
StalkerVodSource,
|
||||
StalkerVodSeriesEpisode,
|
||||
StalkerVodSeriesSeason,
|
||||
} from '../../models';
|
||||
import { StalkerSessionService } from '../../stalker-session.service';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import {
|
||||
sortByNumericValue,
|
||||
@@ -37,6 +40,26 @@ const initialSeriesState: StalkerSeriesState = {
|
||||
selectedVodSeriesSeasonId: undefined,
|
||||
};
|
||||
|
||||
interface ResourceState<T> {
|
||||
value(): T;
|
||||
isLoading(): boolean;
|
||||
}
|
||||
|
||||
interface StalkerSeriesResponse<T> {
|
||||
js?: {
|
||||
data?: T[];
|
||||
};
|
||||
}
|
||||
|
||||
interface StalkerSeriesStoreContext {
|
||||
selectedSerialId(): string | undefined;
|
||||
currentPlaylist(): Playlist | undefined;
|
||||
selectedItem(): StalkerVodSource | null | undefined;
|
||||
selectedContentType(): 'vod' | 'series' | 'itv';
|
||||
serialSeasonsResource: ResourceState<StalkerSeason[]>;
|
||||
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
|
||||
}
|
||||
|
||||
export function withStalkerSeries() {
|
||||
const logger = createLogger('withStalkerSeries');
|
||||
const toMovieId = (value: unknown): string => {
|
||||
@@ -51,18 +74,22 @@ export function withStalkerSeries() {
|
||||
store,
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => ({
|
||||
serialSeasonsResource: resource({
|
||||
) => {
|
||||
const storeContext =
|
||||
store as unknown as StalkerSeriesStoreContext;
|
||||
|
||||
return {
|
||||
serialSeasonsResource: resource({
|
||||
params: () => ({
|
||||
itemId: (store as any).selectedSerialId(),
|
||||
itemId: storeContext.selectedSerialId(),
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
loader: async ({ params }): Promise<StalkerSeason[]> => {
|
||||
const movieId = toMovieId(params.itemId);
|
||||
// Guard: ensure currentPlaylist and itemId are available
|
||||
if (!(store as any).currentPlaylist() || !movieId) {
|
||||
if (!storeContext.currentPlaylist() || !movieId) {
|
||||
return [];
|
||||
}
|
||||
const playlist = (store as any).currentPlaylist() as Playlist;
|
||||
const playlist = storeContext.currentPlaylist() as Playlist;
|
||||
const queryParams = {
|
||||
action: StalkerContentTypes.series.getContentAction,
|
||||
type: 'series',
|
||||
@@ -70,17 +97,16 @@ export function withStalkerSeries() {
|
||||
};
|
||||
|
||||
// Use makeAuthenticatedRequest for automatic retry on auth failure
|
||||
let response: any;
|
||||
let response: StalkerSeriesResponse<StalkerSeason>;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
// Full stalker portal - use authenticated request with retry
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerSeason>>(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
// Simple stalker portal - no auth needed
|
||||
response = await dataService.sendIpcEvent(
|
||||
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerSeason>>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
@@ -104,9 +130,8 @@ export function withStalkerSeries() {
|
||||
*/
|
||||
vodSeriesSeasonsResource: resource({
|
||||
params: () => ({
|
||||
selectedItem: (store as any).selectedItem(),
|
||||
selectedContentType:
|
||||
(store as any).selectedContentType?.() ?? 'vod',
|
||||
selectedItem: storeContext.selectedItem(),
|
||||
selectedContentType: storeContext.selectedContentType(),
|
||||
}),
|
||||
loader: async ({
|
||||
params,
|
||||
@@ -115,12 +140,12 @@ export function withStalkerSeries() {
|
||||
logger.debug('vodSeriesSeasonsResource loader called', {
|
||||
item,
|
||||
isSeries: item?.is_series,
|
||||
currentPlaylist: (store as any).currentPlaylist(),
|
||||
currentPlaylist: storeContext.currentPlaylist(),
|
||||
});
|
||||
|
||||
// Only fetch if item is a VOD series (has is_series flag)
|
||||
if (
|
||||
!(store as any).currentPlaylist() ||
|
||||
!storeContext.currentPlaylist() ||
|
||||
params.selectedContentType !== 'vod' ||
|
||||
!item ||
|
||||
!item.is_series
|
||||
@@ -131,7 +156,7 @@ export function withStalkerSeries() {
|
||||
return [];
|
||||
}
|
||||
|
||||
const playlist = (store as any).currentPlaylist() as Playlist;
|
||||
const playlist = storeContext.currentPlaylist() as Playlist;
|
||||
const queryParams = {
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
@@ -139,15 +164,14 @@ export function withStalkerSeries() {
|
||||
p: '1',
|
||||
};
|
||||
|
||||
let response: any;
|
||||
let response: StalkerSeriesResponse<StalkerVodSeriesSeason>;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
response = await dataService.sendIpcEvent(
|
||||
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
@@ -180,25 +204,26 @@ export function withStalkerSeries() {
|
||||
);
|
||||
return sortVodSeriesSeasonsByNumber(seasons);
|
||||
},
|
||||
}),
|
||||
})
|
||||
}),
|
||||
};
|
||||
}
|
||||
),
|
||||
withComputed((store) => {
|
||||
const storeAny = store as any;
|
||||
const storeContext = store as unknown as StalkerSeriesStoreContext;
|
||||
return {
|
||||
/** serials */
|
||||
getSerialSeasonsResource: computed(() =>
|
||||
storeAny.serialSeasonsResource.value()
|
||||
storeContext.serialSeasonsResource.value()
|
||||
),
|
||||
isSerialSeasonsLoading: computed(() =>
|
||||
storeAny.serialSeasonsResource.isLoading()
|
||||
storeContext.serialSeasonsResource.isLoading()
|
||||
),
|
||||
/** VOD series (Ministra plugin is_series=1) */
|
||||
getVodSeriesSeasonsResource: computed(() =>
|
||||
storeAny.vodSeriesSeasonsResource.value()
|
||||
storeContext.vodSeriesSeasonsResource.value()
|
||||
),
|
||||
isVodSeriesSeasonsLoading: computed(() =>
|
||||
storeAny.vodSeriesSeasonsResource.isLoading()
|
||||
storeContext.vodSeriesSeasonsResource.isLoading()
|
||||
),
|
||||
};
|
||||
}),
|
||||
@@ -208,7 +233,11 @@ export function withStalkerSeries() {
|
||||
dataService = inject(DataService),
|
||||
stalkerSession = inject(StalkerSessionService)
|
||||
) => {
|
||||
const storeAny = store as any;
|
||||
const storeContext =
|
||||
store as unknown as Pick<
|
||||
StalkerSeriesStoreContext,
|
||||
'currentPlaylist'
|
||||
>;
|
||||
|
||||
return {
|
||||
/**
|
||||
@@ -221,7 +250,7 @@ export function withStalkerSeries() {
|
||||
videoId: string,
|
||||
seasonId: string
|
||||
): Promise<StalkerVodSeriesEpisode[]> {
|
||||
const playlist = storeAny.currentPlaylist() as Playlist;
|
||||
const playlist = storeContext.currentPlaylist() as Playlist;
|
||||
if (!playlist) return [];
|
||||
|
||||
const queryParams = {
|
||||
@@ -232,15 +261,14 @@ export function withStalkerSeries() {
|
||||
p: '1',
|
||||
};
|
||||
|
||||
let response: any;
|
||||
let response: StalkerSeriesResponse<StalkerVodSeriesEpisode>;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
response = await dataService.sendIpcEvent(
|
||||
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
export * from './lib/stalker-main-container.component';
|
||||
export * from './lib/stalker-inline-detail/stalker-inline-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/stalker-favorites.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-shell/stalker-shell.component';
|
||||
export * from './lib/recently-viewed/recently-viewed.component';
|
||||
+25
@@ -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 />
|
||||
}
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
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);
|
||||
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 {
|
||||
this.closeInlinePlayer();
|
||||
this.catalog.clearSelectedItem();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Provider, Injectable, computed, inject } from '@angular/core';
|
||||
import {
|
||||
DestroyRef,
|
||||
Injectable,
|
||||
Provider,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
buildStalkerSelectedVodItem,
|
||||
StalkerStore,
|
||||
@@ -9,8 +17,25 @@ import {
|
||||
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
|
||||
@@ -22,6 +47,15 @@ export class StalkerCatalogFacadeService
|
||||
>
|
||||
{
|
||||
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;
|
||||
@@ -69,6 +103,51 @@ export class StalkerCatalogFacadeService
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -108,8 +187,34 @@ export class StalkerCatalogFacadeService
|
||||
}
|
||||
|
||||
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
|
||||
void item;
|
||||
return {};
|
||||
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(
|
||||
@@ -155,6 +260,52 @@ export class StalkerCatalogFacadeService
|
||||
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[] {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+11
-2
@@ -5,7 +5,16 @@ 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 } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
matchesFavoriteById,
|
||||
StalkerSelectedVodItem,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
|
||||
type FavoriteButtonItem = StalkerSelectedVodItem & {
|
||||
name?: string;
|
||||
o_name?: string;
|
||||
cover?: string;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites-button',
|
||||
@@ -18,7 +27,7 @@ export class FavoritesButtonComponent {
|
||||
private stalkerStore = inject(StalkerStore);
|
||||
|
||||
readonly itemId = input.required<string>();
|
||||
readonly item = input.required<any>();
|
||||
readonly item = input.required<FavoriteButtonItem>();
|
||||
|
||||
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
|
||||
type ComponentLoader = NonNullable<Route['loadComponent']>;
|
||||
@@ -56,7 +58,13 @@ export function createStalkerRoutes(): Route[] {
|
||||
},
|
||||
{
|
||||
path: 'vod',
|
||||
providers: provideStalkerCatalogFacade(),
|
||||
providers: [
|
||||
...provideStalkerCatalogFacade(),
|
||||
{
|
||||
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
useValue: StalkerCatalogDetailComponent,
|
||||
},
|
||||
],
|
||||
loadComponent: loadStalkerMainContainerComponent,
|
||||
children: [
|
||||
{
|
||||
@@ -88,7 +96,13 @@ export function createStalkerRoutes(): Route[] {
|
||||
},
|
||||
{
|
||||
path: 'series',
|
||||
providers: provideStalkerCatalogFacade(),
|
||||
providers: [
|
||||
...provideStalkerCatalogFacade(),
|
||||
{
|
||||
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
useValue: StalkerCatalogDetailComponent,
|
||||
},
|
||||
],
|
||||
loadComponent: loadStalkerMainContainerComponent,
|
||||
children: [
|
||||
{
|
||||
|
||||
+2
-4
@@ -7,11 +7,9 @@ import {
|
||||
} 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,
|
||||
VodDetailsComponent,
|
||||
} from 'components';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-inline-detail',
|
||||
|
||||
+1
-1
@@ -23,10 +23,10 @@ import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
ChannelListItemComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
} from 'components';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { EpgItem, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
|
||||
@@ -4,7 +4,8 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { ResizableDirective } from 'components';
|
||||
import {
|
||||
CategoryViewComponent,
|
||||
PlaylistErrorViewComponent,
|
||||
|
||||
@@ -4,8 +4,9 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { selectPlaylistById } from 'm3u-state';
|
||||
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Playlist,
|
||||
STALKER_REQUEST,
|
||||
@@ -44,6 +45,14 @@ interface StalkerFilter {
|
||||
translationKey: string;
|
||||
}
|
||||
|
||||
interface StalkerSearchResponse {
|
||||
js?: {
|
||||
data?: StalkerVodSource[];
|
||||
};
|
||||
message?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-search',
|
||||
imports: [
|
||||
@@ -136,9 +145,10 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.dataService.sendIpcEvent(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<StalkerSearchResponse>(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params: {
|
||||
@@ -150,8 +160,8 @@ export class StalkerSearchComponent {
|
||||
},
|
||||
token,
|
||||
serialNumber,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
if (response) {
|
||||
const items = response.js?.data || [];
|
||||
return items.map((item: StalkerVodSource) =>
|
||||
|
||||
File renamed without changes.
+6
-4
@@ -10,10 +10,12 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from '../content-hero/content-hero.component';
|
||||
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
|
||||
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
|
||||
import { SeasonContainerComponent } from '../season-container/season-container.component';
|
||||
import {
|
||||
ContentHeroComponent,
|
||||
SeasonContainerComponent,
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
} from 'components';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -40,8 +42,8 @@ import {
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { DownloadsService } from 'services';
|
||||
import { SeasonContainerPlaybackToggleRequest } from '../season-container/season-container.component';
|
||||
|
||||
/**
|
||||
* Component for displaying series/episodes for Stalker portal content.
|
||||
@@ -0,0 +1,588 @@
|
||||
// ============================================================================
|
||||
// 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.375rem;
|
||||
font-weight: 600;
|
||||
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: 1.125rem;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
+2
-1
@@ -23,7 +23,8 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent, PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
|
||||
@@ -17,7 +17,6 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import { ContentCardComponent, FavoriteLayoutItem } from '@iptvnator/portal/shared/ui';
|
||||
@@ -82,6 +81,22 @@ interface XtreamRecentLiveItem {
|
||||
readonly xtream_id: number;
|
||||
}
|
||||
|
||||
function groupItemsByPlaylistName(
|
||||
items: XtreamRecentViewItem[]
|
||||
): Record<string, XtreamRecentViewItem[]> {
|
||||
return items.reduce<Record<string, XtreamRecentViewItem[]>>(
|
||||
(groups, item) => {
|
||||
const key = String(item.playlist_name);
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(item);
|
||||
return groups;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function isXtreamRecentViewItem(
|
||||
item: FavoriteLayoutItem
|
||||
): item is XtreamRecentViewItem {
|
||||
@@ -476,8 +491,7 @@ export class RecentlyViewedComponent {
|
||||
getGroupedItems() {
|
||||
const items = this.visibleRecentItems();
|
||||
if (!this.isGlobal) return { default: items };
|
||||
const grouped = groupBy(items, 'playlist_name');
|
||||
return grouped;
|
||||
return groupItemsByPlaylistName(items);
|
||||
}
|
||||
|
||||
updateRecentSearchTerm(term: string) {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { DatabaseService } from 'services';
|
||||
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
|
||||
@@ -40,6 +39,22 @@ interface SearchResultsData {
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
function groupResultsByPlaylistName(
|
||||
items: XtreamContentItem[]
|
||||
): Record<string, XtreamContentItem[]> {
|
||||
return items.reduce<Record<string, XtreamContentItem[]>>(
|
||||
(groups, item) => {
|
||||
const key = String(item.playlist_name);
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(item);
|
||||
return groups;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-results',
|
||||
imports: [
|
||||
@@ -121,7 +136,7 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
readonly groupedResults = computed(() => {
|
||||
const results = this.xtreamStore.searchResults();
|
||||
if (!this.isGlobalSearch) return { default: results };
|
||||
return groupBy(results, 'playlist_name');
|
||||
return groupResultsByPlaylistName(results);
|
||||
});
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -6,10 +6,9 @@ import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
ContentHeroComponent,
|
||||
PortalInlinePlayerComponent,
|
||||
SeasonContainerComponent,
|
||||
SeasonContainerPlaybackToggleRequest,
|
||||
SeasonContainerXtreamDownloadContext,
|
||||
SeasonContainerComponent,
|
||||
} from 'components';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent, PortalInlinePlayerComponent } from 'components';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import { SafePipe } from '@iptvnator/pipes';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getPortalPlaybackProgressPercent,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { DownloadsService, SettingsStore } from 'services';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
|
||||
@@ -9,7 +9,8 @@ import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"sourceRoot": "libs/services/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "type:data-access"],
|
||||
"tags": ["scope:shared", "domain:shared-runtime", "type:data-access"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
export * from './lib/data.service';
|
||||
export * from './lib/database-electron.service';
|
||||
export * from './lib/downloads.service';
|
||||
export * from './lib/epg.service';
|
||||
export * from './lib/epg-progress.service';
|
||||
export * from './lib/playback-position.service';
|
||||
export * from './lib/playlists.service';
|
||||
export * from './lib/portal-status.service';
|
||||
export * from './lib/settings-store.service';
|
||||
export * from './lib/sort.service';
|
||||
export * from './lib/stalker-session.service';
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Params } from '@angular/router';
|
||||
type ElectronProcessWindow = Window & {
|
||||
process?: {
|
||||
type?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export abstract class DataService {
|
||||
get isElectron(): boolean {
|
||||
return !!(
|
||||
window &&
|
||||
window['process'] &&
|
||||
(window['process'] as any).type
|
||||
);
|
||||
const currentWindow = window as ElectronProcessWindow;
|
||||
return Boolean(currentWindow.process?.type);
|
||||
}
|
||||
get remote() {
|
||||
return null;
|
||||
@@ -15,11 +16,14 @@ export abstract class DataService {
|
||||
return null;
|
||||
}
|
||||
abstract getAppVersion(): string;
|
||||
abstract sendIpcEvent(type: string, payload?: unknown): any;
|
||||
abstract sendIpcEvent<T = unknown>(
|
||||
type: string,
|
||||
payload?: unknown
|
||||
): T | Promise<T>;
|
||||
abstract removeAllListeners(type: string): void;
|
||||
abstract listenOn(
|
||||
command: string,
|
||||
callback: (...args: any[]) => void
|
||||
callback: (...args: unknown[]) => void
|
||||
): void;
|
||||
abstract getAppEnvironment(): string;
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
XtreamCategory,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
export interface XCategoryFromDb {
|
||||
id: number;
|
||||
@@ -37,6 +40,31 @@ export interface XtreamPlaylist {
|
||||
type: string;
|
||||
}
|
||||
|
||||
type XtreamDatabasePlaylistUpdate = {
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
serverUrl?: string;
|
||||
lastUpdated?: string;
|
||||
};
|
||||
|
||||
type XtreamContentStream =
|
||||
| {
|
||||
category_id: string | number;
|
||||
rating?: string | number;
|
||||
rating_imdb?: string;
|
||||
last_modified?: string;
|
||||
added?: string;
|
||||
stream_icon?: string;
|
||||
poster?: string;
|
||||
cover?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
series_id?: string | number;
|
||||
stream_id?: string | number;
|
||||
}
|
||||
| Record<string, unknown>;
|
||||
|
||||
export interface GlobalSearchResult extends XtreamContent {
|
||||
playlist_id: string;
|
||||
playlist_name: string;
|
||||
@@ -103,7 +131,9 @@ export class DatabaseService {
|
||||
/**
|
||||
* Update playlist basic info
|
||||
*/
|
||||
async updateXtreamPlaylist(playlist: any): Promise<boolean> {
|
||||
async updateXtreamPlaylist(
|
||||
playlist: Pick<XtreamPlaylist, 'id' | 'name'>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await window.electron.dbUpdatePlaylist(playlist.id, {
|
||||
name: playlist.name,
|
||||
@@ -127,7 +157,7 @@ export class DatabaseService {
|
||||
updateDate?: number;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const updates: any = {};
|
||||
const updates: XtreamDatabasePlaylistUpdate = {};
|
||||
if (playlist.title) updates.name = playlist.title;
|
||||
if (playlist.username) updates.username = playlist.username;
|
||||
if (playlist.password) updates.password = playlist.password;
|
||||
@@ -171,7 +201,7 @@ export class DatabaseService {
|
||||
*/
|
||||
async saveXtreamCategories(
|
||||
playlistId: string,
|
||||
categories: any[],
|
||||
categories: XtreamCategory[],
|
||||
type: 'live' | 'movies' | 'series',
|
||||
hiddenCategoryXtreamIds?: number[]
|
||||
): Promise<void> {
|
||||
@@ -237,7 +267,7 @@ export class DatabaseService {
|
||||
*/
|
||||
async saveXtreamContent(
|
||||
playlistId: string,
|
||||
streams: any[],
|
||||
streams: XtreamContentStream[],
|
||||
type: 'live' | 'movie' | 'series',
|
||||
onProgress?: (count: number) => void
|
||||
): Promise<number> {
|
||||
|
||||
@@ -24,12 +24,26 @@ import {
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
PlaylistUpdateState,
|
||||
XtreamItem,
|
||||
XtreamSerieItem,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
|
||||
type PortalFavoriteItem = StalkerPortalItem & {
|
||||
category_id?: string;
|
||||
raw?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type PortalRecentlyViewedItem = StalkerPortalItem & {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type PlaylistRawItem = {
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -255,7 +269,7 @@ export class PlaylistsService {
|
||||
const playlist = await window.electron.dbGetAppPlaylist(id);
|
||||
return playlist
|
||||
? this.createSqliteFallbackPlaylist(playlist as Playlist)
|
||||
: (undefined as any);
|
||||
: (undefined as unknown as Playlist);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -395,7 +409,7 @@ export class PlaylistsService {
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
map((item) => {
|
||||
if (!item || !item.favorites) return [];
|
||||
return item.favorites as Partial<XtreamItem>[];
|
||||
return item.favorites as PortalFavoriteItem[];
|
||||
}),
|
||||
map((favorites) =>
|
||||
favorites.sort(
|
||||
@@ -411,7 +425,7 @@ export class PlaylistsService {
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
map((item) => {
|
||||
if (!item || !item.favorites) return [];
|
||||
return (item.favorites as Partial<XtreamItem>[]).filter(
|
||||
return (item.favorites as PortalFavoriteItem[]).filter(
|
||||
(itm) =>
|
||||
itm && itm.stream_type && itm.stream_type === 'live'
|
||||
);
|
||||
@@ -419,7 +433,7 @@ export class PlaylistsService {
|
||||
);
|
||||
}
|
||||
|
||||
addPortalFavorite(portalId: string, item: any) {
|
||||
addPortalFavorite(portalId: string, item: PortalFavoriteItem) {
|
||||
if (!portalId) {
|
||||
throw new Error('Portal ID is required');
|
||||
}
|
||||
@@ -449,16 +463,11 @@ export class PlaylistsService {
|
||||
...portal,
|
||||
favorites: portal.favorites?.filter((i) => {
|
||||
const expectedId = String(favoriteId);
|
||||
const streamId = String(
|
||||
(i as Partial<XtreamItem>).stream_id ?? ''
|
||||
);
|
||||
const seriesId = String(
|
||||
(i as Partial<XtreamSerieItem>).series_id ?? ''
|
||||
);
|
||||
const movieId = String(
|
||||
(i as Partial<{ movie_id: string }>).movie_id ?? ''
|
||||
);
|
||||
const itemId = String((i as any).id ?? '');
|
||||
const favorite = i as PortalFavoriteItem;
|
||||
const streamId = String(favorite.stream_id ?? '');
|
||||
const seriesId = String(favorite.series_id ?? '');
|
||||
const movieId = String(favorite.movie_id ?? '');
|
||||
const itemId = String(favorite.id ?? '');
|
||||
|
||||
return (
|
||||
streamId !== expectedId &&
|
||||
@@ -614,7 +623,7 @@ export class PlaylistsService {
|
||||
`${playlist.playlist?.header?.raw ?? ''}` +
|
||||
'\n' +
|
||||
(playlist.playlist?.items ?? [])
|
||||
.map((item: any) => item.raw)
|
||||
.map((item: PlaylistRawItem) => item.raw)
|
||||
.join('\n')
|
||||
);
|
||||
})
|
||||
@@ -652,7 +661,7 @@ export class PlaylistsService {
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
map((item) => {
|
||||
if (!item || !item.recentlyViewed) return [];
|
||||
return item.recentlyViewed as Partial<XtreamItem>[];
|
||||
return item.recentlyViewed as PortalRecentlyViewedItem[];
|
||||
}),
|
||||
map((items) =>
|
||||
items.sort(
|
||||
@@ -666,7 +675,7 @@ export class PlaylistsService {
|
||||
|
||||
addPortalRecentlyViewed(
|
||||
portalId: string,
|
||||
item: { id: string; title: string }
|
||||
item: PortalRecentlyViewedItem & { id: string | number; title: string }
|
||||
) {
|
||||
if (!portalId) {
|
||||
throw new Error('Portal ID is required');
|
||||
@@ -674,9 +683,10 @@ export class PlaylistsService {
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) => {
|
||||
const nowIso = new Date().toISOString();
|
||||
const recentItems = portal.recentlyViewed ?? [];
|
||||
const recentItems =
|
||||
(portal.recentlyViewed as PortalRecentlyViewedItem[]) ?? [];
|
||||
const existingIndex = recentItems.findIndex(
|
||||
(i: any) => String(i?.id) === String(item.id)
|
||||
(recentItem) => String(recentItem?.id) === String(item.id)
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -708,7 +718,7 @@ export class PlaylistsService {
|
||||
...recentItems,
|
||||
{
|
||||
...item,
|
||||
added_at: (item as any).added_at ?? nowIso,
|
||||
added_at: item.added_at ?? nowIso,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -736,8 +746,10 @@ export class PlaylistsService {
|
||||
|
||||
const nextPlaylist: Playlist = {
|
||||
...portal,
|
||||
recentlyViewed: portal.recentlyViewed?.filter((i: any) => {
|
||||
const itemId = String(i?.id ?? '');
|
||||
recentlyViewed: (
|
||||
portal.recentlyViewed as PortalRecentlyViewedItem[]
|
||||
)?.filter((item) => {
|
||||
const itemId = String(item?.id ?? '');
|
||||
const itemNormalized = normalizePortalItemId(itemId);
|
||||
return (
|
||||
itemId !== expectedId &&
|
||||
|
||||
@@ -3,6 +3,15 @@ import { DataService } from './data.service';
|
||||
|
||||
export type PortalStatus = 'active' | 'inactive' | 'expired' | 'unavailable';
|
||||
|
||||
interface XtreamPortalStatusResponse {
|
||||
payload?: {
|
||||
user_info?: {
|
||||
status?: string;
|
||||
exp_date?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -28,26 +37,27 @@ export class PortalStatusService {
|
||||
normalizedUrl = serverUrl;
|
||||
}
|
||||
|
||||
let response = await this.dataService.sendIpcEvent(
|
||||
'XTREAM_REQUEST',
|
||||
{
|
||||
url: normalizedUrl,
|
||||
params: {
|
||||
password,
|
||||
username,
|
||||
action: 'get_account_info',
|
||||
},
|
||||
}
|
||||
);
|
||||
response = response?.payload;
|
||||
const response =
|
||||
await this.dataService.sendIpcEvent<XtreamPortalStatusResponse>(
|
||||
'XTREAM_REQUEST',
|
||||
{
|
||||
url: normalizedUrl,
|
||||
params: {
|
||||
password,
|
||||
username,
|
||||
action: 'get_account_info',
|
||||
},
|
||||
}
|
||||
);
|
||||
const payload = response?.payload;
|
||||
|
||||
if (!response?.user_info?.status) {
|
||||
if (!payload?.user_info?.status) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
if (response.user_info.status === 'Active') {
|
||||
if (payload.user_info.status === 'Active') {
|
||||
const expDate = new Date(
|
||||
parseInt(response.user_info.exp_date) * 1000
|
||||
parseInt(payload.user_info.exp_date) * 1000
|
||||
);
|
||||
return expDate < new Date() ? 'expired' : 'active';
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/database/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "type:data-access"],
|
||||
"tags": ["scope:shared", "domain:shared-runtime", "type:data-access"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/esbuild:esbuild",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/interfaces/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "type:util"],
|
||||
"tags": ["scope:shared", "domain:shared-contracts", "type:util"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"sourceRoot": "libs/ui/components/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "type:ui"],
|
||||
"tags": ["scope:shared", "domain:shared-ui", "type:ui"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
export * from './lib/add-playlist-menu/add-playlist-menu.component';
|
||||
export * from './lib/art-player/art-player.component';
|
||||
export * from './lib/audio-player/audio-player.component';
|
||||
export * from './lib/channel-list-container/channel-list-container.component';
|
||||
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
|
||||
export * from './lib/confirm-dialog/confirm-dialog.component';
|
||||
export * from './lib/confirm-dialog/dialog.service';
|
||||
export * from './lib/content-hero/content-hero.component';
|
||||
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
|
||||
export * from './lib/epg-list/epg-list-item/epg-list-item.component';
|
||||
export * from './lib/epg-list/epg-list.component';
|
||||
export * from './lib/epg-progress-panel/epg-progress-panel.component';
|
||||
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
|
||||
export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/filter-sort-menu/filter-sort-menu.component';
|
||||
export * from './lib/html-video-player/html-video-player.component';
|
||||
export * from './lib/info-overlay/info-overlay.component';
|
||||
export * from './lib/multi-epg/multi-epg-container.component';
|
||||
export * from './lib/multi-epg/overlay-ref.token';
|
||||
export * from './lib/playlist-switcher/playlist-switcher.component';
|
||||
export * from './lib/portal-inline-player/portal-inline-player.component';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
|
||||
export * from './lib/recent-playlists/playlist-item/playlist-item.component';
|
||||
export * from './lib/recent-playlists/recent-playlists.component';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
|
||||
export * from './lib/stalker-series-view/stalker-series-view.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
export * from './lib/video-player/toolbar/toolbar.component';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/vod-details/vod-details.component';
|
||||
export * from './lib/watched-badge/watched-badge.component';
|
||||
export * from './lib/web-player-view/web-player-view.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
+13
-3
@@ -16,7 +16,7 @@ import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
ChannelActions,
|
||||
FavoritesActions,
|
||||
@@ -25,12 +25,22 @@ import {
|
||||
selectFavorites,
|
||||
} from 'm3u-state';
|
||||
import { BehaviorSubject, combineLatest, filter, map, skipWhile } from 'rxjs';
|
||||
import { EpgService } from 'services';
|
||||
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
|
||||
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
|
||||
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
|
||||
import { GroupsViewComponent } from './groups-view/groups-view.component';
|
||||
|
||||
function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
|
||||
return channels.reduce<Record<string, Channel[]>>((groups, channel) => {
|
||||
const key = channel.group?.title ?? '';
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(channel);
|
||||
return groups;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-container',
|
||||
templateUrl: './channel-list-container.component.html',
|
||||
@@ -123,7 +133,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Object with channels sorted by groups */
|
||||
readonly groupedChannels = computed(() =>
|
||||
groupBy(this.displayedChannels(), 'group.title')
|
||||
groupChannelsByTitle(this.displayedChannels())
|
||||
);
|
||||
|
||||
/** Selected channel */
|
||||
|
||||
+1
-1
@@ -14,8 +14,8 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { EpgItemDescriptionComponent } from '../../epg-list/epg-item-description/epg-item-description.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-item',
|
||||
|
||||
@@ -28,13 +28,13 @@ export class InfoOverlayComponent implements OnChanges {
|
||||
finishedDuration!: number;
|
||||
|
||||
/** Program start time */
|
||||
start: any | undefined;
|
||||
start: string | undefined;
|
||||
|
||||
/** Program end time */
|
||||
stop: any | undefined;
|
||||
stop: string | undefined;
|
||||
|
||||
/** Timeout for the overlay visibility */
|
||||
private currentTimeout: any;
|
||||
private currentTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/**
|
||||
* Calculates the necessary information for the visualization in the overview popup
|
||||
@@ -67,17 +67,19 @@ export class InfoOverlayComponent implements OnChanges {
|
||||
* @param start program start time
|
||||
* @param stop program stop time
|
||||
*/
|
||||
setProgramDuration(start: number, stop: number): void {
|
||||
this.stop = moment(stop, 'YYYYMMDDHHmm ZZ');
|
||||
this.start = moment(start, 'YYYYMMDDHHmm ZZ');
|
||||
setProgramDuration(start: string | number, stop: string | number): void {
|
||||
const stopMoment = moment(stop, 'YYYYMMDDHHmm ZZ');
|
||||
const startMoment = moment(start, 'YYYYMMDDHHmm ZZ');
|
||||
this.stop = stopMoment.toISOString();
|
||||
this.start = startMoment.toISOString();
|
||||
const timeNow = moment(Date.now());
|
||||
|
||||
this.generalDuration = moment
|
||||
.duration(this.stop.diff(this.start))
|
||||
.duration(stopMoment.diff(startMoment))
|
||||
.asMilliseconds();
|
||||
|
||||
this.finishedDuration = moment
|
||||
.duration(this.stop.diff(timeNow))
|
||||
.duration(stopMoment.diff(timeNow))
|
||||
.asMilliseconds();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
Renderer2,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
OnInit,
|
||||
@@ -78,11 +79,10 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
|
||||
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
|
||||
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
|
||||
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
|
||||
private readonly el = inject(ElementRef<HTMLElement>);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
|
||||
constructor(
|
||||
private readonly el: ElementRef<HTMLElement>,
|
||||
private readonly renderer: Renderer2
|
||||
) {
|
||||
constructor() {
|
||||
// Apply resizing class when dragging
|
||||
effect(() => {
|
||||
if (this.isResizing()) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "ui-epg",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/ui/epg/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:ui"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user