Merge pull request #747 from 4gray/refactor/xtream-stalker-code-generalization

Refactor/xtream stalker code generalization
This commit is contained in:
4gray authored and GitHub committed 2026-02-01 16:06:09 +01:00
commit 0526d69a4c
64 files changed
+2929 -833

No files matched your search

+8 -1
View File
@@ -18,7 +18,14 @@
"Bash(npx tsc:*)",
"Bash(find:*)",
"Bash(xargs sed:*)",
"Skill(frontend-design)"
"Skill(frontend-design)",
"mcp__angular-cli__get_best_practices",
"mcp__nx-mcp__nx_workspace",
"Bash(grep:*)",
"Bash(npx nx lint:*)",
"Bash(npx nx build:*)",
"Bash(npx eslint:*)",
"Bash(npx nx run:*)"
],
"deny": [],
"ask": []
+1 -1
View File
@@ -9,7 +9,7 @@
<img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/4gray/iptvnator/build-and-test.yaml?style=for-the-badge&logo=github"> <a href="https://github.com/4gray/iptvnator/releases"><img src="https://img.shields.io/github/downloads/4gray/iptvnator/total?style=for-the-badge&logo=github" alt="Releases"></a> <a href="https://codecov.io/gh/4gray/iptvnator"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/4gray/iptvnator?style=for-the-badge"></a> <a href="https://t.me/iptvnator"><img src="https://img.shields.io/badge/telegram-iptvnator-blue?logo=telegram&style=for-the-badge" alt="Telegram"></a> <a href="https://bsky.app/profile/iptvnator.bsky.social"><img src="https://img.shields.io/badge/bluesky-iptvnator-darkblue?logo=bluesky&style=for-the-badge" alt="Bluesky"></a>
</p>
<a href="https://t.me/iptvnator">Telegram channel for discussions</a>
<a href="https://t.me/iptvnator">Telegram channel for discussions</a> | <a href="https://ko-fi.com/4gray" target="_blank">Buy me a coffee</a> | <a href="https://github.com/sponsors/4gray">GitHub Sponsors</a>
**IPTVnator** is a video player application that provides support for IPTV playlist playback (m3u, m3u8). The application allows users to import playlists using remote URLs or by uploading files from the local file system. Additionally, it supports EPG information in XMLTV format which can be provided via URL.
@@ -8,6 +8,31 @@ import { ipcMain } from 'electron';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
/**
* Ensure playlist exists in SQLite (auto-create if missing)
* This handles legacy playlists that weren't synced to SQLite
*/
async function ensurePlaylistExists(
db: any,
playlistId: string,
playlistType: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url' = 'stalker'
): Promise<void> {
const existing = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
if (existing.length === 0) {
// Create a minimal placeholder entry for the playlist
await db.insert(schema.playlists).values({
id: playlistId,
name: 'Imported Playlist',
type: playlistType,
});
}
}
/**
* Save/update playback position for content
*/
@@ -15,13 +40,11 @@ ipcMain.handle(
'DB_SAVE_PLAYBACK_POSITION',
async (event, playlistId: string, data: any) => {
try {
console.log(
'[DatabaseEvents] Saving playback position:',
playlistId,
data
);
const db = await getDatabase();
// Ensure playlist exists (handles legacy playlists not in SQLite)
await ensurePlaylistExists(db, playlistId, data.playlistType);
// Prepare values
const values = {
playlistId,
@@ -83,9 +106,6 @@ ipcMain.handle(
contentType: 'vod' | 'episode'
) => {
try {
console.log(
`[DatabaseEvents] Getting playback position: playlistId=${playlistId}, id=${contentXtreamId}, type=${contentType}`
);
const db = await getDatabase();
const result = await db
.select()
@@ -21,6 +21,8 @@ ipcMain.handle(
serverUrl?: string;
username?: string;
password?: string;
macAddress?: string;
url?: string;
type: string;
}
) => {
@@ -32,6 +34,8 @@ ipcMain.handle(
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
macAddress: playlist.macAddress,
url: playlist.url,
// enforce supported types
type: playlist.type as
| 'xtream'
@@ -1,6 +1,18 @@
<mat-drawer-container class="main-container">
<mat-drawer-container class="main-container" autosize>
<!-- sidebar content -->
<mat-drawer #drawer mode="side" opened disableClose class="mat-drawer">
<mat-drawer
#drawer
mode="side"
opened
disableClose
class="mat-drawer"
appResizable
[minWidth]="200"
[maxWidth]="600"
[defaultWidth]="350"
storageKey="iptvnator_video_sidebar_width"
handlePosition="right"
>
@defer {
<app-sidebar [channels]="(channels$ | async)!" />
} @loading {
@@ -4,6 +4,7 @@
bottom: 0;
left: 0;
right: 0;
overflow: hidden; // Prevent horizontal scrollbar from resize handle
}
.mat-drawer-content {
@@ -13,8 +14,18 @@
}
.mat-drawer {
width: 400px;
min-width: 400px;
// Width is controlled by appResizable directive
// Default: 350px, min: 200px, max: 600px
overflow-x: hidden !important; // Hide horizontal scrollbar
.resize-handle--right {
right: 0;
}
}
// Ensure mat-drawer inner container fills the drawer width
::ng-deep .mat-drawer-inner-container {
width: 100% !important;
}
.custom-player {
@@ -23,6 +23,7 @@ import {
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MultiEpgContainerComponent,
ResizableDirective,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
@@ -64,6 +65,7 @@ import { SettingsComponent } from '../../settings/settings.component';
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MatSidenavModule,
ResizableDirective,
RouterLink,
SidebarComponent,
ToolbarComponent,
@@ -1,4 +1,11 @@
<div class="sidebar">
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
<app-playlist-switcher
class="switcher"
@@ -5,7 +5,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from 'components';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
@Component({
@@ -23,6 +23,7 @@ import { CategoryViewComponent } from '../../../xtream-tauri/category-view/categ
MatIconButton,
MatTooltip,
PlaylistSwitcherComponent,
ResizableDirective,
TranslatePipe,
],
})
@@ -59,7 +59,7 @@ import { WatchedBadgeComponent } from '../../../xtream-tauri/shared/watched-badg
<mat-icon>star</mat-icon>{{ rating }}
</div>
}
@let title = i.title ?? i.name;
@let title = i.title ?? i.o_name ?? i.name;
<mat-card-actions>
<div class="title">
{{ title || 'No name' }}
@@ -1,13 +1,5 @@
export interface StalkerSerialDetails {
name: string;
description: string;
director: string;
actors: string;
year: string;
genres_str: string;
age: string;
rating_imdb: string;
rating_kinopoisk: string;
screenshot_uri: string;
added: string;
}
/**
* @deprecated Import from 'shared-interfaces' instead.
* This re-export exists for backward compatibility.
*/
export { StalkerSerialDetails } from 'shared-interfaces';
@@ -1,15 +1,8 @@
export interface StalkerVodDetails {
id: string;
cmd: string;
info: {
movie_image: string;
description: string;
name: string;
actors: string;
director: string;
releasedate: string;
genre: string;
rating_imdb: string;
rating_kinopoisk: string;
};
}
/**
* @deprecated Import from 'shared-interfaces' instead.
* This re-export exists for backward compatibility.
*/
export {
StalkerVodDetails,
StalkerVodInfo,
} from 'shared-interfaces';
@@ -1,30 +1,29 @@
@if (itemDetails) {
<button
mat-button
(click)="itemDetails = null"
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
class="back-button"
>
<mat-icon>arrow_back</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
@switch (itemDetails.category_id) {
@case ('vod') {
<app-vod-details
[item]="itemDetails"
(playClicked)="
createLinkToPlayVodItv(
itemDetails.cmd,
itemDetails.info.name,
itemDetails.info.movie_image
)
"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
/>
@if (itemDetails.series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails"
(backClicked)="itemDetails = null; vodDetailsItem = null"
/>
} @else if (itemDetails.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view
(backClicked)="itemDetails = null; vodDetailsItem = null"
/>
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view />
<app-stalker-series-view (backClicked)="itemDetails = null" />
}
}
} @else {
@@ -1,9 +1,12 @@
import { Component, computed, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
VodDetailsItem,
StalkerVodDetails,
createStalkerVodItem,
} from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@@ -14,10 +17,7 @@ import { StalkerStore } from '../stalker.store';
templateUrl: './recently-viewed.component.html',
imports: [
FavoritesLayoutComponent,
MatButton,
MatIcon,
StalkerSeriesViewComponent,
TranslatePipe,
VodDetailsComponent,
],
styles: [
@@ -42,6 +42,7 @@ export class RecentlyViewedComponent {
private readonly translate = inject(TranslateService);
itemDetails: any = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
@@ -130,9 +131,30 @@ export class RecentlyViewedComponent {
this.stalkerStore.setSelectedContentType(item.category_id);
switch (item.category_id) {
case 'itv':
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
break;
case 'vod':
// Normalize the item to ensure is_series flag is properly set
const normalizedItem = this.normalizeRecentItem(item);
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem);
// Check if this VOD item is actually a series (Ministra is_series=1)
const isVodSeries =
normalizedItem.is_series === true ||
normalizedItem.series?.length > 0;
if (isVodSeries) {
// VOD series - will be rendered as series view
this.vodDetailsItem = null;
} else {
// Regular VOD - create VodDetailsItem for the component
this.vodDetailsItem = createStalkerVodItem(
normalizedItem as StalkerVodDetails,
this.currentPlaylist()?._id ?? ''
);
}
break;
case 'series':
this.itemDetails = item;
this.stalkerStore.setSelectedItem(item);
@@ -142,6 +164,40 @@ export class RecentlyViewedComponent {
}
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
if (event.item.type === 'stalker') {
if (event.isFavorite) {
this.addToFavorites({
...event.item.data,
category_id: 'vod',
title: event.item.data.info?.name,
cover: event.item.data.info?.movie_image,
added_at: new Date().toISOString(),
});
} else {
this.removeFromFavorites(event.item.data.id);
}
}
}
/** Handle back from vod-details component */
onVodBack(): void {
this.itemDetails = null;
this.vodDetailsItem = null;
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
@@ -158,4 +214,33 @@ export class RecentlyViewedComponent {
removeFromFavorites(favoriteId: string) {
this.stalkerStore.removeFromFavorites(favoriteId);
}
/**
* Normalize recent item to ensure is_series flag is properly set
* and all required fields are present for the series view
*/
private normalizeRecentItem(item: any): any {
// Normalize is_series to boolean true (can be 1, "1", or true)
const rawIsSeries = item.is_series;
const normalizedIsSeries =
rawIsSeries === true ||
rawIsSeries === 1 ||
rawIsSeries === '1';
// Ensure info object exists
const info = item.info || {
name: item.o_name || item.name || item.title || 'Unknown',
o_name: item.o_name,
movie_image: item.cover || item.screenshot_uri || item.logo,
description: item.description,
};
return {
...item,
info: info,
is_series: normalizedIsSeries ? true : undefined,
// Preserve video_id for season fetching
video_id: item.video_id,
};
}
}
@@ -1,17 +1,31 @@
@if (itemDetails) {
@switch (itemDetails.category_id) {
@case ('vod') {
<app-vod-details
[item]="itemDetails.details"
(playClicked)="
createLinkToPlayVodItv(
itemDetails.details.cmd,
itemDetails.details.info.name,
itemDetails.details.info.movie_image
)
"
(backClicked)="itemDetails = null"
/>
@if (itemDetails.details?.series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails.details"
(backClicked)="itemDetails = null; vodDetailsItem = null"
/>
} @else if (
itemDetails.details?.is_series === true ||
itemDetails.details?.is_series === 1 ||
itemDetails.details?.is_series === '1'
) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view
(backClicked)="itemDetails = null; vodDetailsItem = null"
/>
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="true"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
@@ -1,9 +1,12 @@
import { Component, computed, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
VodDetailsItem,
StalkerVodDetails,
createStalkerVodItem,
} from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@@ -39,6 +42,7 @@ export class StalkerFavoritesComponent {
private readonly translate = inject(TranslateService);
itemDetails: any = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
@@ -130,12 +134,32 @@ export class StalkerFavoritesComponent {
switch (item.category_id) {
case 'itv':
this.stalkerStore.setSelectedContentType('itv');
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
break;
case 'vod':
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem.details);
this.stalkerStore.setSelectedContentType('vod');
// Check if this VOD item is actually a series (Ministra is_series=1)
const isVodSeries =
normalizedItem.details?.is_series === true ||
normalizedItem.details?.is_series === 1 ||
normalizedItem.details?.is_series === '1' ||
item.is_series === true ||
item.is_series === 1 ||
item.is_series === '1';
if (isVodSeries || normalizedItem.details?.series?.length > 0) {
// VOD series - will be rendered as series view
this.vodDetailsItem = null;
} else {
// Regular VOD - create VodDetailsItem for the component
this.vodDetailsItem = createStalkerVodItem(
normalizedItem.details as StalkerVodDetails,
this.currentPlaylist()?._id ?? ''
);
}
break;
case 'series':
this.itemDetails = normalizedItem;
@@ -147,6 +171,30 @@ export class StalkerFavoritesComponent {
}
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
if (!event.isFavorite && event.item.type === 'stalker') {
this.removeFromFavorites({ id: event.item.data.id });
}
}
/** Handle back from vod-details component */
onVodBack(): void {
this.itemDetails = null;
this.vodDetailsItem = null;
}
private normalizeFavoriteItem(item: any): any {
if (item.details && item.details.info) {
return item;
@@ -154,7 +202,8 @@ export class StalkerFavoritesComponent {
const source = item.details || item;
const info = source.info || {
name: item.name || item.title || 'Unknown',
name: item.o_name || item.name || item.title || 'Unknown',
o_name: item.o_name,
movie_image: item.cover || item.screenshot_uri || item.logo,
description: item.description,
releasedate: item.releasedate || item.year,
@@ -169,6 +218,14 @@ export class StalkerFavoritesComponent {
[item.cover, item.screenshot_uri].filter(Boolean),
};
// Normalize is_series to boolean true (can be 1, "1", or true)
const rawIsSeries =
item.is_series || source.is_series;
const normalizedIsSeries =
rawIsSeries === true ||
rawIsSeries === 1 ||
rawIsSeries === '1';
return {
...item,
details: {
@@ -177,7 +234,9 @@ export class StalkerFavoritesComponent {
cmd: item.cmd || source.cmd,
id: item.stream_id || item.id || source.id,
series: item.series || source.series,
is_series: item.is_series || source.is_series,
is_series: normalizedIsSeries ? true : undefined,
// Preserve video_id for season fetching
video_id: item.video_id || source.video_id,
},
};
}
@@ -1,4 +1,11 @@
<div class="sidebar" style="display: flex; flex-direction: column">
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
@let isItvCategorySelected =
stalkerStore.selectedCategoryId() &&
@@ -31,7 +38,7 @@
"
alt="logo"
/>
{{ item.name }}
{{ item.o_name || item.name }}
<button
mat-icon-button
matListItemMeta
@@ -90,7 +97,7 @@
}
@if (!isItvCategorySelected) {
{{ selectedCategoryTitle() }}
{{ getContentLabel() }}
}
@if (!contentItems() && !isContentLoading()) {
@@ -8,17 +8,17 @@ import {
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistsService } from 'services';
import { PlaylistSwitcherComponent } from 'components';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../xtream-tauri/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
import { StalkerStore } from './stalker.store';
@@ -33,7 +33,6 @@ import { StalkerStore } from './stalker.store';
],
imports: [
CategoryViewComponent,
MatButton,
MatIcon,
MatIconButton,
MatListModule,
@@ -42,12 +41,14 @@ import { StalkerStore } from './stalker.store';
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
ResizableDirective,
RouterOutlet,
TranslatePipe,
],
})
export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly translateService = inject(TranslateService);
currentLayout:
| 'category_content'
@@ -282,7 +283,7 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
createLinkToPlayItv(item: any) {
this.stalkerStore.setSelectedItem(item);
this.stalkerStore.createLinkToPlayVod(item.cmd, item.name, item.logo);
this.stalkerStore.createLinkToPlayVod(item.cmd, item.o_name || item.name, item.logo);
}
toggleFavorite(item: any) {
@@ -296,7 +297,7 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
this.stalkerStore.addToFavorites({
...item,
category_id: 'itv',
title: item.name,
title: item.o_name || item.name,
cover: item.logo,
added_at: new Date().toISOString(),
});
@@ -329,4 +330,21 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
goBackToList() {
this.stalkerStore.clearSelectedItem();
}
getContentLabel(): string {
const categoryName = this.stalkerStore.getSelectedCategoryName();
// Show page number when viewing category content (not detail view)
if (
!this.stalkerStore.selectedItem() &&
this.stalkerStore.getTotalPages() > 1
) {
const currentPage = this.stalkerStore.page() + 1;
const totalPages = this.stalkerStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
}
return categoryName;
}
}
@@ -1,19 +1,24 @@
@if (itemDetails) {
@switch (selectedFilterType()) {
@case ('vod') {
<app-vod-details
[item]="itemDetails"
(playClicked)="
createLinkToPlayVodItv(
itemDetails.cmd,
itemDetails.info.name,
itemDetails.info.movie_image
)
"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
(backClicked)="itemDetails = null"
/>
@if (itemDetails.series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails"
(backClicked)="onVodBack()"
/>
} @else if (itemDetails.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onVodBack()" />
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
@@ -43,7 +48,7 @@
track item.id
) {
<app-search-result-item
[title]="item.name"
[title]="item.o_name || item.name"
[posterUrl]="item.screenshot_uri"
[type]="item.type"
(itemClick)="selectItem(item)"
@@ -10,6 +10,9 @@ import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
VodDetailsItem,
StalkerVodDetails,
createStalkerVodItem,
} from 'shared-interfaces';
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
@@ -82,7 +85,8 @@ export class StalkerSearchComponent {
readonly selectedFilterType = signal('vod');
itemDetails = null;
itemDetails: any = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly searchResultsResource = resource({
params: () => ({
@@ -130,7 +134,11 @@ export class StalkerSearchComponent {
}
);
if (response) {
return response.js?.data;
// Process items to convert relative URLs to absolute
const items = response.js?.data || [];
return items.map((item: any) =>
this.processItemUrls(item, portalUrl)
);
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
@@ -144,15 +152,62 @@ export class StalkerSearchComponent {
}
selectItem(item: any) {
// Check if item has embedded series data (vclub mode or Ministra with episodes)
const hasEmbeddedSeries = item.series?.length > 0;
// Detect if this VOD item is a series that needs API fetch (Ministra is_series flag WITHOUT embedded series)
// Only set is_series when we need to fetch seasons from API
const needsSeriesFetch =
this.selectedFilterType() === 'vod' &&
!hasEmbeddedSeries &&
(item.is_series === '1' || item.is_series === 1);
// Structure item exactly like category-content-view does
this.itemDetails = {
...item,
info: { ...item, movie_image: item.screenshot_uri },
id: item.id,
cmd: item.cmd,
// For VOD items with embedded series array (Stalker vclub or Ministra with episode numbers)
series: item.series,
// Preserve has_files for cmd transformation during playback
has_files: item.has_files,
// Flag for VOD items that need to fetch seasons from API (Ministra plugin without embedded series)
is_series: needsSeriesFetch ? true : undefined,
// Store video_id for season fetching if available
video_id: item.video_id,
info: {
movie_image: item.screenshot_uri,
description: item.description,
name: item.o_name || item.name,
o_name: item.o_name,
director: item.director,
releasedate: item.year,
genre: item.genres_str,
actors: item.actors,
rating_imdb: item.rating_imdb,
rating_kinopoisk: item.rating_kinopoisk,
},
};
// Debug logging
console.log('[StalkerSearch] selectItem - hasEmbeddedSeries:', hasEmbeddedSeries);
console.log('[StalkerSearch] selectItem - needsSeriesFetch:', needsSeriesFetch);
console.log('[StalkerSearch] selectItem - itemDetails:', this.itemDetails);
this.stalkerStore.setSelectedItem(this.itemDetails);
switch (this.selectedFilterType()) {
case 'vod':
this.stalkerStore.setSelectedContentType('vod');
// Only create VodDetailsItem for regular VODs (not series of any kind)
if (!hasEmbeddedSeries && !needsSeriesFetch) {
const playlist = this.currentPlaylist();
this.vodDetailsItem = createStalkerVodItem(
this.itemDetails as StalkerVodDetails,
playlist?._id ?? ''
);
} else {
this.vodDetailsItem = null;
}
break;
case 'series':
this.stalkerStore.setSelectedContentType('series');
@@ -162,6 +217,40 @@ export class StalkerSearchComponent {
}
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVodItv(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
if (event.item.type === 'stalker') {
if (event.isFavorite) {
this.addToFavorites({
...event.item.data,
category_id: 'vod',
title: event.item.data.info?.name,
cover: event.item.data.info?.movie_image,
added_at: new Date().toISOString(),
});
} else {
this.removeFromFavorites(event.item.data.id);
}
}
}
/** Handle back from vod-details component */
onVodBack(): void {
this.itemDetails = null;
this.vodDetailsItem = null;
}
onFiltersChange(event: { vod: boolean; live: boolean; series: boolean }) {
const selectedFilter = Object.keys(event).find((key) => event[key]);
if (selectedFilter) {
@@ -178,4 +267,47 @@ export class StalkerSearchComponent {
console.debug('Remove from favorites', favoriteId);
this.stalkerStore.removeFromFavorites(favoriteId);
}
/**
* Convert relative URLs to absolute URLs using the portal base URL.
* Ministra portals often return relative paths for screenshot_uri.
*/
private processItemUrls(item: any, portalUrl: string): any {
const processed = { ...item };
// Convert screenshot_uri to absolute URL
if (processed.screenshot_uri) {
processed.screenshot_uri = this.makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
/**
* Convert relative URL to absolute using portal base URL
*/
private makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
// Already absolute URL
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
// Parse the base URL to get origin
try {
const url = new URL(baseUrl);
// Ensure the relative path starts with /
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
}
@@ -13,6 +13,7 @@ import { ContentHeroComponent } from 'components';
import { StalkerStore } from '../stalker.store';
import { SeasonContainerComponent } from '../../xtream-tauri/season-container/season-container.component';
import { XtreamSerieEpisode } from 'shared-interfaces';
import { XtreamStore } from '../../xtream-tauri/stores/xtream.store';
/**
* VOD series season with episodes loaded dynamically
@@ -57,6 +58,7 @@ interface StalkerSeriesSeason {
})
export class StalkerSeriesViewComponent {
readonly stalkerStore = inject(StalkerStore);
private readonly xtreamStore = inject(XtreamStore);
readonly backClicked = output<void>();
/**
@@ -74,10 +76,15 @@ export class StalkerSeriesViewComponent {
/**
* Indicates if this is a VOD series item (Ministra is_series=1)
* Note: is_series can be true, 1, or "1" depending on the source
*/
readonly isVodSeries = computed(() => {
const item = this.displayItem();
return item?.is_series === true;
return (
item?.is_series === true ||
item?.is_series === 1 ||
item?.is_series === '1'
);
});
/**
@@ -95,7 +102,11 @@ export class StalkerSeriesViewComponent {
// Effect to load VOD series seasons when a VOD series item is selected
effect(() => {
const item = this.displayItem();
if (item?.is_series === true) {
const isSeries =
item?.is_series === true ||
item?.is_series === 1 ||
item?.is_series === '1';
if (isSeries) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
if (seasons && seasons.length > 0) {
@@ -118,6 +129,22 @@ export class StalkerSeriesViewComponent {
this.vodSeriesSeasons.set([]);
}
});
// Effect to load playback positions for Stalker series
effect(() => {
const item = this.displayItem();
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const seriesId = Number(item.id);
console.log(
`[StalkerSeriesView] Loading positions for series: id=${item.id}, seriesId=${seriesId}, is_series=${item.is_series}`
);
if (!isNaN(seriesId)) {
// Load playback positions from the XtreamStore (works for any playlist type)
this.xtreamStore.loadSeriesPositions(playlist._id, seriesId);
}
}
});
}
/**
@@ -149,6 +176,42 @@ export class StalkerSeriesViewComponent {
return this.vodWithSeries() || this.selectedItem();
});
/**
* Simple hash function to generate consistent numeric IDs from strings
*/
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Generate a unique episode ID for Stalker episodes.
* For VOD series: ALWAYS hash with season + episode number because
* the API returns the same ID (series/movie ID) for all episodes.
* For regular series: hash the cmd + episode number
*/
private generateEpisodeId(
episodeId: any,
episodeNum: number,
seasonKey: string,
isVodSeries: boolean
): number {
if (isVodSeries) {
// For VOD series, ALWAYS create unique ID from season + episode number
// The API-provided ep.id is the same for all episodes (it's the series ID)
return this.hashString(`vod_${seasonKey}_${episodeNum}`);
} else {
// For regular series, create unique ID from cmd + episode number
const cmd = String(episodeId || '');
return this.hashString(`${cmd}_ep_${episodeNum}`);
}
}
/**
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
@@ -164,13 +227,13 @@ export class StalkerSeriesViewComponent {
season.season_number || season.name || season.id;
// Map existing episodes or empty array
mapped[seasonKey] = (season.episodes || []).map(
(ep) =>
({
episode_num:
ep.series_number || ep.episode_num || 0,
(ep) => {
const episodeNum = ep.series_number || ep.episode_num || 0;
return {
episode_num: episodeNum,
title:
ep.name ||
`Episode ${ep.series_number || ep.episode_num}`,
`Episode ${episodeNum}`,
container_extension: 'mpg',
info: {
movie_image:
@@ -181,9 +244,13 @@ export class StalkerSeriesViewComponent {
? `${ep.duration} min`
: '',
},
id: ep.id,
// Use unique ID for watched status tracking
id: this.generateEpisodeId(ep.id, episodeNum, seasonKey, true),
// Store original API id for playback
originalId: ep.id,
custom_sid: 'vod-series', // marker
}) as unknown as XtreamSerieEpisode
} as unknown as XtreamSerieEpisode;
}
);
});
} else {
@@ -201,7 +268,10 @@ export class StalkerSeriesViewComponent {
this.displayItem().info.movie_image,
},
custom_sid: 'regular-series', // marker
id: season.cmd, // Store cmd in id for playEpisodeClicked
// Use unique ID for watched status tracking
id: this.generateEpisodeId(season.cmd, epNum, seasonKey, false),
// Store cmd for playback
originalCmd: season.cmd,
}) as unknown as XtreamSerieEpisode
);
});
@@ -290,32 +360,49 @@ export class StalkerSeriesViewComponent {
* Handles episode click from the container
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
if (episode.custom_sid === 'vod-series') {
// It's a VOD series episode
// We reconstructed the episode object, but playVodSeriesEpisode expects the raw object
// or at least { id, name, series_number }
if ((episode as any).custom_sid === 'vod-series') {
// It's a VOD series episode (is_series=1 mode)
// Use originalId for playback URL, but use generated id for tracking
this.playVodSeriesEpisode({
id: episode.id,
originalId: (episode as any).originalId, // For constructing playback URL
trackingId: episode.id, // The generated unique ID for position tracking
name: episode.title,
series_number: episode.episode_num,
});
} else {
// Regular series
// episode.id contains the 'cmd' from mapping
this.playEpisodeClicked(episode.episode_num, episode.id);
// Regular series or vclub mode (VOD with embedded series array)
// Use originalCmd for playback, episode.id for tracking
const trackingId = Number(episode.id);
this.playEpisodeClicked(
episode.episode_num,
(episode as any).originalCmd,
trackingId
);
}
}
/**
* Play episode - handles both regular series and VOD series
* Play episode - handles regular series and vclub mode
*/
playEpisodeClicked(episodeNum: any, cmd?: string) {
playEpisodeClicked(episodeNum: any, cmd?: string, trackingId?: number) {
const item = this.displayItem();
console.log(
`[StalkerSeriesView] playEpisodeClicked: episodeNum=${episodeNum}, cmd=${cmd}, trackingId=${trackingId}, seriesId=${item.id}`
);
// Get playback position from XtreamStore if available
const position = trackingId
? this.xtreamStore.playbackPositions().get(`episode_${trackingId}`)
: undefined;
const startTime = position?.positionSeconds;
this.stalkerStore.createLinkToPlayVod(
cmd,
item.info.name,
item.info.movie_image,
episodeNum
episodeNum,
trackingId, // unique episode ID for playback tracking
startTime
);
}
@@ -324,13 +411,29 @@ export class StalkerSeriesViewComponent {
*/
playVodSeriesEpisode(episode: any) {
const item = this.displayItem();
const cmd = `/media/file_${episode.id}.mpg`;
// Use originalId for playback URL (this is what the Stalker API expects)
const cmd = `/media/file_${episode.originalId}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
// Use trackingId (generated unique ID) for playback position tracking
const trackingId = Number(episode.trackingId);
console.log(
`[StalkerSeriesView] playVodSeriesEpisode: originalId=${episode.originalId}, trackingId=${trackingId}, seriesId=${item.id}, episodeName=${episodeName}`
);
// Get playback position from XtreamStore if available
const position = this.xtreamStore
.playbackPositions()
.get(`episode_${trackingId}`);
const startTime = position?.positionSeconds;
this.stalkerStore.createLinkToPlayVod(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image
item.info.movie_image,
episode.series_number, // episode number for API
trackingId, // unique episode ID for playback tracking
startTime
);
}
+89 -10
View File
@@ -441,8 +441,15 @@ export const StalkerStore = signalStore(
}),
loader: async ({ params }) => {
const item = params.selectedItem;
// Debug logging
console.log('[StalkerStore] vodSeriesSeasonsResource loader called');
console.log('[StalkerStore] item:', item);
console.log('[StalkerStore] item?.is_series:', item?.is_series);
console.log('[StalkerStore] currentPlaylist:', store.currentPlaylist());
// Only fetch if item is a VOD series (has is_series flag)
if (!store.currentPlaylist() || !item || !item.is_series) {
console.log('[StalkerStore] vodSeriesSeasonsResource - early return, conditions not met');
return [];
}
@@ -473,13 +480,17 @@ export const StalkerStore = signalStore(
}
if (!response?.js?.data) {
console.log('[StalkerStore] vodSeriesSeasonsResource - no response data');
return [];
}
console.log('[StalkerStore] vodSeriesSeasonsResource - response.js.data:', response.js.data);
// Filter for season items (is_season: true)
const seasons = response.js.data.filter(
(item: any) => item.is_season === true
);
console.log('[StalkerStore] vodSeriesSeasonsResource - filtered seasons:', seasons);
return sortByNumericValue(seasons);
},
}),
@@ -572,8 +583,40 @@ export const StalkerStore = signalStore(
setPage(page: number) {
patchState(store, { page });
},
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
dataService.isElectron &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await window.electron.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
console.error(
'Error syncing Stalker playlist to SQLite:',
error
);
}
}
},
setSelectedItem(selectedItem: any) {
// TODO: check the item type and proper property either selectedVodId or serialsId etc
@@ -666,9 +709,31 @@ export const StalkerStore = signalStore(
return episodes;
},
/** getters */
getSelectedCategory() {
return signal<string>(store.selectedCategoryId());
},
getSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
id: 0,
name: 'All Items',
type: store.selectedContentType(),
};
}
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: any[] = [];
if (contentType === 'vod') {
categories = store.vodCategories();
} else if (contentType === 'series') {
categories = store.seriesCategories();
} else if (contentType === 'itv') {
categories = store.itvCategories();
}
return categories.find(
(c: any) => String(c.category_id) === String(categoryId)
) || { id: categoryId, name: '', type: contentType };
}),
/** API */
async fetchLinkToPlay(
portalUrl: string,
@@ -891,11 +956,22 @@ export const StalkerStore = signalStore(
return null;
},
/**
* Play VOD or episode content
* @param cmd The media command/path
* @param title Display title
* @param thumbnail Thumbnail URL
* @param episodeNum Episode number (for series param in API)
* @param episodeId Optional episode ID for playback tracking (defaults to item.id)
* @param startTime Optional start time in seconds for resume playback
*/
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string,
episode?: number
episodeNum?: number,
episodeId?: number,
startTime?: number
) {
try {
const item = this.selectedItem();
@@ -919,7 +995,7 @@ export const StalkerStore = signalStore(
this.currentPlaylist().portalUrl,
this.currentPlaylist().macAddress,
cmdToUse,
episode
episodeNum
);
this.addToRecentlyViewed({
...item,
@@ -929,11 +1005,13 @@ export const StalkerStore = signalStore(
title,
});
const playlist = this.currentPlaylist();
const isEpisode = episodeNum !== undefined || episodeId !== undefined;
const contentInfo = {
playlistId: playlist._id,
contentXtreamId: Number(item.id),
contentType: episode ? 'episode' : 'vod',
seriesXtreamId: episode ? Number(item.id) : undefined, // For Stalker VOD series, use item.id as series ID
// For episodes, use episodeId if provided, otherwise fall back to item.id
contentXtreamId: isEpisode && episodeId ? episodeId : Number(item.id),
contentType: isEpisode ? 'episode' : 'vod',
seriesXtreamId: isEpisode ? Number(item.id) : undefined,
};
playerService.openPlayer(
@@ -945,7 +1023,8 @@ export const StalkerStore = signalStore(
playlist?.userAgent,
playlist?.referrer,
playlist?.origin,
contentInfo
contentInfo,
startTime
);
} catch (error) {
console.error(
@@ -2,26 +2,22 @@
@if (isStalker && selectedItem()) {
@if (contentType() === 'vod') {
@if (selectedItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
} @else if (selectedItem().is_series) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view />
} @else {
<app-vod-details
[item]="selectedItem()"
(playClicked)="
createLinkToPlayVod(
selectedItem().cmd,
selectedItem().info.name,
selectedItem().info.movie_image
)
"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="
removeFromFavorites($any($event))
"
/>
@if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[playbackPosition]="selectedVodPlaybackPosition()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
@@ -1,12 +1,19 @@
import { Component, inject, OnInit, computed } from '@angular/core';
import { Component, inject, OnInit, OnDestroy, computed, signal, effect } from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
VodDetailsItem,
StalkerVodDetails,
createStalkerVodItem,
PlaybackPositionData,
} from 'shared-interfaces';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../../stalker/stalker.store';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { XTREAM_DATA_SOURCE } from '../data-sources';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../stores/xtream.store';
@@ -22,15 +29,19 @@ import { XtreamStore } from '../stores/xtream.store';
VodDetailsComponent,
],
})
export class CategoryContentViewComponent implements OnInit {
export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
private readonly store = this.isStalker
? inject(StalkerStore)
: inject(XtreamStore);
/** Unsubscribe function for playback position updates */
private unsubscribePositionUpdates: (() => void) | null = null;
readonly contentType = this.store.selectedContentType;
readonly limit = this.store.limit;
@@ -39,32 +50,103 @@ export class CategoryContentViewComponent implements OnInit {
readonly selectedCategory = this.store.getSelectedCategory;
readonly paginatedContent = this.store.getPaginatedContent;
/** 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() {
// Effect to load Stalker positions when playlist becomes available
if (this.isStalker) {
effect(() => {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (playlistId && playlistId !== this.loadedPositionsForPlaylistId) {
this.loadedPositionsForPlaylistId = playlistId;
this.loadStalkerPositions(playlistId);
}
});
// Effect to load position for selected VOD item (ensures fresh data after playback)
effect(() => {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const item = this.selectedItem();
const playlist = stalkerStore.currentPlaylist();
if (item && playlist?._id && this.contentType() === 'vod') {
this.loadSingleVodPosition(playlist._id, Number(item.id));
}
});
// Listen for real-time playback position updates from video player
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);
}
}
);
}
}
}
readonly contentWithProgress = computed(() => {
const items = this.store.getPaginatedContent();
if (!items) return [];
if (this.isStalker) return items;
if (this.isStalker) {
// Enrich Stalker items with progress data
const positions = this.stalkerPositions();
const seriesPositions = this.stalkerSeriesPositions();
const contentType = this.contentType();
return items.map((item: any) => {
const id = Number(item.id);
// Check if this item has series/episode progress (watched episodes)
// This works even when is_series flag isn't set on the item
const hasSeriesProgress = seriesPositions.has(id) && seriesPositions.get(id)!.length > 0;
// For series content type, or items with is_series flag
const isSeries = contentType === 'series' || item.is_series === '1' || item.is_series === 1;
if (hasSeriesProgress) {
return { ...item, hasSeriesProgress: true };
} else if (isSeries) {
return { ...item, hasSeriesProgress: false };
} else {
// Regular VOD item
const vodKey = `vod_${id}`;
const vodPosition = positions.get(vodKey);
const vodProgress = this.calculateProgress(vodPosition);
return {
...item,
progress: vodProgress,
isWatched: vodProgress >= 90,
};
}
});
}
const xtreamStore = this.store as any;
if (!xtreamStore.getProgressPercent) return items;
return items.map((item: any) => {
const isSeries = this.contentType() === 'series';
// Use xtream_id (DB) or series_id/stream_id (API)
const id = Number(
item.xtream_id || item.series_id || item.stream_id
);
// console.log(`[CategoryContent] Processing item ${id}. isSeries=${isSeries}`);
const id = Number(item.xtream_id || item.series_id || item.stream_id);
if (isSeries) {
const hasProgress = xtreamStore.hasSeriesProgress(id);
console.log(
`[CategoryContent] Checking series ${id} (xtream_id=${item.xtream_id}, series_id=${item.series_id}). Has progress: ${hasProgress}`
);
return {
...item,
hasSeriesProgress: hasProgress,
hasSeriesProgress: xtreamStore.hasSeriesProgress(id),
};
} else {
return {
@@ -81,15 +163,39 @@ export class CategoryContentViewComponent implements OnInit {
readonly totalPages = this.store.getTotalPages;
readonly bigStore = inject(Store);
/** Computed VodDetailsItem for the vod-details component */
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
const item = this.selectedItem();
if (!item || !this.isStalker) return null;
// Access currentPlaylist from the store (type-safe since we're in stalker mode)
const stalkerStore = this.store as unknown as {
currentPlaylist: () => { _id: string } | null;
};
return createStalkerVodItem(
item as StalkerVodDetails,
stalkerStore.currentPlaylist()?._id ?? ''
);
});
/** Playback position for the selected VOD item (Stalker) */
readonly selectedVodPlaybackPosition = computed<number | null>(() => {
if (!this.isStalker) return null;
const item = this.selectedItem();
if (!item) return null;
const id = Number(item.id);
const vodKey = `vod_${id}`;
const position = this.stalkerPositions().get(vodKey);
return position?.positionSeconds ?? null;
});
seasons = [];
ngOnInit() {
const { categoryId } = this.activatedRoute.snapshot.params;
console.log(
`[CategoryContent] ngOnInit. contentType=${this.contentType()}, isStalker=${this.isStalker}, categoryId=${categoryId}`
);
// Ensure playback positions are loaded (Xtream only)
// Ensure playback positions are loaded (Xtream only - Stalker is handled via effect in constructor)
if (!this.isStalker) {
const xtreamStore = this.store as any;
if (xtreamStore.currentPlaylist()?.id) {
@@ -101,20 +207,71 @@ export class CategoryContentViewComponent implements OnInit {
// This ensures the content-header is visible
this.store.setSelectedItem(null);
// Only set category if it's different from the currently selected one
// Set category - setSelectedCategory only resets page if category actually changes
// This preserves the page state when navigating back from detail view
if (categoryId) {
if (this.store.selectedCategoryId() !== Number(categoryId)) {
this.store.setSelectedCategory(categoryId);
}
this.store.setSelectedCategory(categoryId);
} else {
// No categoryId in route means "All Items"
if (this.store.selectedCategoryId() !== null) {
this.store.setSelectedCategory(null);
}
this.store.setSelectedCategory(null);
}
}
/**
* Load all playback positions for a Stalker portal
*/
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions = await this.dataSource.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);
// Group episodes by series ID
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.dataSource.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 watched > 10s but percent < 1, return 1 to show visual progress
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
onPageChange(event: PageEvent) {
this.store.setPage(event.pageIndex);
this.store.setLimit(event.pageSize);
@@ -140,7 +297,8 @@ export class CategoryContentViewComponent implements OnInit {
info: {
movie_image: item.screenshot_uri,
description: item.description,
name: item.name || item.o_name,
name: item.o_name || item.name,
o_name: item.o_name,
director: item.director,
releasedate: item.year,
genre: item.genres_str,
@@ -184,4 +342,59 @@ export class CategoryContentViewComponent implements OnInit {
console.debug('Remove from favorites', favoriteId);
this.store.removeFromFavorites(favoriteId);
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
this.createLinkToPlayVod(
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') {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
stalkerStore.createLinkToPlayVod(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
undefined, // episodeNum
undefined, // episodeId
event.positionSeconds
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
if (event.item.type === 'stalker') {
if (event.isFavorite) {
this.addToFavorites({
...event.item.data,
category_id: 'vod',
title: event.item.data.info?.name,
cover: event.item.data.info?.movie_image,
added_at: new Date().toISOString(),
});
} else {
this.removeFromFavorites(event.item.data.id);
}
}
}
/** Handle back from vod-details component */
onVodBack(): void {
this.store.setSelectedItem(null);
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
}
@@ -28,7 +28,8 @@ export class CategoryViewComponent {
const selectedCategory = this.selectedCategoryId();
const itemId = Number((item as any).category_id ?? item.id);
return selectedCategory !== null && selectedCategory === itemId;
// Compare as strings to handle both number and string category IDs
return selectedCategory != null && String(selectedCategory) === String(itemId);
}
getItemCount(item: XtreamCategory): number {
@@ -1,7 +1,11 @@
:host {
width: 100%;
}
.downloads {
display: flex;
flex-direction: column;
height: 100%;
height: calc(100vh - 20px);
padding: 1rem;
overflow: hidden;
@@ -40,11 +40,19 @@ export class DownloadsComponent implements OnInit {
readonly hasDownloads = this.downloadsService.hasDownloads;
readonly activeCount = this.downloadsService.activeCount;
/** Filter downloads for current playlist */
/** Filter downloads for current playlist and sort by newest first */
readonly filteredDownloads = computed(() => {
const playlistId = this.route.snapshot.params['id'];
if (!playlistId) return this.downloads();
return this.downloads().filter((d) => d.playlistId === playlistId);
const downloads = playlistId
? this.downloads().filter((d) => d.playlistId === playlistId)
: this.downloads();
// Sort by createdAt descending (newest first)
return [...downloads].sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
});
});
ngOnInit() {
@@ -1,13 +1,20 @@
@let isEmbeddedPlayer =
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
<div class="sidebar">
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
@if (selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}</h2>
<h2>{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})</h2>
} @else {
<app-playlist-switcher
class="switcher"
@@ -9,9 +9,29 @@
}
.sidebar {
width: 300px;
min-width: 300px;
// Width controlled by resizable directive
width: 400px; // Default, will be overridden by directive
min-width: 250px;
max-width: 600px;
border-right: 1px solid #111;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
position: relative;
app-portal-channels-list {
flex: 1;
overflow: hidden;
width: 100%;
}
app-category-view {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
margin-right: 2px;
}
}
.content-container {
@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
} from '@angular/core';
@@ -14,7 +15,7 @@ import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from 'components';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { XtreamCategory } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { SettingsStore } from '../../services/settings-store.service';
@@ -44,6 +45,7 @@ import { XtreamStore } from '../stores/xtream.store';
/* MpvPlayerBarComponent, */
PlaylistSwitcherComponent,
PortalChannelsListComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
@@ -62,6 +64,22 @@ export class LiveStreamLayoutComponent implements OnInit {
readonly epgItems = this.xtreamStore.epgItems;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly selectedCategoryInfo = computed(() => {
const categoryId = this.selectedCategoryId();
if (!categoryId) return null;
const categories = this.categories();
const category = categories?.find(
(c: any) => (c.category_id ?? c.id) === categoryId
);
const count = this.categoryItemCounts()?.get(categoryId) ?? 0;
return {
name: category?.category_name ?? category?.name ?? 'Channels',
count,
};
});
readonly player = this.settingsStore.player;
streamUrl: string;
favorites = new Map<number, boolean>();
@@ -11,32 +11,28 @@
</mat-form-field>
</div>
<mat-nav-list>
<div class="channels-list">
<cdk-virtual-scroll-viewport
[itemSize]="48"
[itemSize]="68"
class="scroll-viewport-portals"
>
<mat-list-item
<div
*cdkVirtualFor="
let item of channels() | filterBy: searchString() : 'title';
trackBy: trackBy;
templateCacheSize: 0
"
templateCacheSize="200"
class="channel-item"
[class.active]="xtreamStore.selectedItem()?.xtream_id === item.xtream_id"
(click)="playClicked.emit(item)"
[activated]="
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
"
>
<img
matListItemAvatar
class="stream-icon"
[src]="item.stream_icon ?? item.poster_url"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
(error)="$event.target.src = './assets/images/default-poster.png'"
alt="channel icon"
/>
<div matListItemTitle class="channel-info">
<div class="channel-info">
<div class="title">{{ item.title }}</div>
@if (currentPrograms.get(item.xtream_id)) {
<div class="program-info">
@@ -53,9 +49,7 @@
<div
class="progress"
[style.width.%]="
currentProgramsProgress.get(
item.xtream_id
)
currentProgramsProgress.get(item.xtream_id)
"
></div>
</div>
@@ -78,16 +72,13 @@
}
</div>
<button
mat-icon-button
matListItemMeta
class="favorite-btn"
(click)="toggleFavorite($event, item)"
>
<mat-icon>
{{
favorites.get(item.xtream_id) ? 'star' : 'star_outline'
}}
{{ favorites.get(item.xtream_id) ? 'star' : 'star_outline' }}
</mat-icon>
</button>
</mat-list-item>
</div>
</cdk-virtual-scroll-viewport>
</mat-nav-list>
</div>
@@ -1,70 +1,176 @@
@use '@angular/material' as mat;
mat-nav-list {
:host {
display: block;
height: 100%;
width: 100%;
overflow: hidden;
display: block;
container-type: inline-size;
}
mat-list-item {
width: 370px !important;
.channels-list {
display: flex;
flex-direction: column;
height: calc(100% - 56px); // Account for search panel
overflow: hidden;
width: 100%;
}
.scroll-viewport-portals {
min-height: calc(100vh - 114px) !important;
height: 100%;
width: 100%;
padding: 4px 0;
}
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100%); // Account for avatar and favorite button
min-width: 0;
flex: 1;
}
mat-list-item {
width: 325px;
min-width: 0; // Prevents flex items from overflowing
box-sizing: border-box;
height: 80px !important;
transition: background-color 0.2s ease;
border-radius: 4px;
margin: 2px 8px;
::ng-deep .mdc-list-item__content {
overflow: hidden;
flex: 1;
// Ensure the virtual scroll content respects parent width
::ng-deep .cdk-virtual-scroll-content-wrapper {
width: 100% !important;
max-width: 100% !important;
contain: layout style;
}
}
.channel-item {
display: flex;
align-items: center;
padding: 8px;
margin: 2px 4px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
height: 64px;
box-sizing: border-box;
gap: 8px;
// Critical: use margin-based calc and ensure it doesn't overflow
width: calc(100% - 8px);
min-width: 0; // Allow shrinking below content size
overflow: hidden;
&:hover {
z-index: 1;
background-color: rgba(255, 255, 255, 0.1);
.channel-info {
.title {
color: #1db954;
}
.title {
color: #1db954;
}
}
&.mat-mdc-list-item-active {
&.active {
background-color: rgba(29, 185, 84, 0.2);
.channel-info .title {
.title {
color: #1db954;
}
}
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
.stream-icon {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
}
.channel-info {
flex: 1 1 0; // grow, shrink, basis=0 forces it to shrink
min-width: 0; // Critical for text truncation in flex
max-width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
.title {
font-weight: 600;
font-size: 14px;
transition: color 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-program {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
.progress-container {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
opacity: 0.6;
span {
flex-shrink: 0;
}
.progress-bar {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
min-width: 20px;
.progress {
height: 100%;
background: #1db954;
transition: width 1s linear;
}
}
}
.no-epg-placeholder {
opacity: 0.5;
.placeholder-bar {
height: 10px;
width: 60%;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
margin: 2px 0;
}
}
}
.favorite-btn {
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: inherit;
opacity: 0.6;
transition: opacity 0.2s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
opacity: 1;
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.search-panel {
display: flex;
justify-content: center;
padding: 8px 12px;
flex-shrink: 0;
height: 56px;
box-sizing: border-box;
}
.search-bar {
width: 100%;
@include mat.form-field-density(-5);
}
::ng-deep {
@@ -79,111 +185,54 @@ mat-list-item {
}
}
.stream-icon {
background: none;
border-radius: 4px;
width: 48px;
height: 48px;
object-fit: cover;
margin-right: 16px;
}
.channel-info {
display: flex;
flex-direction: column;
width: 100%;
gap: 4px;
.title {
font-weight: 600; // Slightly bolder
font-size: 14px;
transition: color 0.2s ease;
width: 100%;
position: relative;
overflow: hidden;
&:hover {
overflow: visible;
text-overflow: clip;
animation: marquee 6s linear infinite;
}
}
.current-program {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
margin-top: 2px;
&:hover {
overflow: visible;
text-overflow: clip;
animation: marquee 6s linear infinite;
}
}
.program-info {
display: flex;
flex-direction: column;
// Container queries for responsive behavior
@container (max-width: 350px) {
.channel-item {
gap: 6px;
padding: 6px;
}
.stream-icon {
width: 40px;
height: 40px;
}
.favorite-btn {
padding: 4px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.progress-container {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
opacity: 0.6;
padding: 0 2px;
.progress-bar {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
opacity: 0.6;
border-radius: 2px;
overflow: hidden;
.progress {
height: 100%;
background: #1db954;
transition: width 1s linear;
}
}
display: none !important;
}
.no-epg-placeholder {
opacity: 0.7;
.placeholder-bar {
height: 10px;
width: 60%;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
margin: 4px 0;
}
.progress-container {
opacity: 0.5;
.progress-bar {
background: rgba(255, 255, 255, 0.05);
}
}
display: none !important;
}
}
@keyframes marquee {
0% {
transform: translateX(0);
@container (max-width: 300px) {
.stream-icon {
width: 32px;
height: 32px;
}
100% {
transform: translateX(calc(-100%));
.favorite-btn {
display: none;
}
}
// Override Material styles to allow animation
::ng-deep .mdc-list-item__primary-text {
overflow: visible !important;
@container (max-width: 260px) {
.stream-icon {
display: none;
}
.channel-item {
height: 48px;
}
}
@@ -14,13 +14,9 @@ import {
ViewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { FilterPipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
@@ -46,14 +42,10 @@ interface EpgProgram {
FilterPipe,
FormsModule,
MatFormFieldModule,
ScrollingModule,
MatCardModule,
MatIcon,
MatIconButton,
MatListModule,
MatInputModule,
ScrollingModule,
TranslatePipe,
MatTooltipModule,
],
})
export class PortalChannelsListComponent implements AfterViewInit {
@@ -1,11 +1,29 @@
<section class="seasons-section">
<div class="section-header">
<h3 class="section-title">Seasons and Episodes</h3>
<div class="section-header__left">
<h3 class="section-title">Seasons and Episodes</h3>
@if (selectedSeason) {
<button class="back-btn" (click)="selectedSeason = undefined">
<mat-icon>arrow_back</mat-icon>
<span>Back to seasons</span>
</button>
}
</div>
@if (selectedSeason) {
<button class="back-btn" (click)="selectedSeason = undefined">
<mat-icon>arrow_back</mat-icon>
<span>Back to seasons</span>
</button>
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle value="grid" [matTooltip]="'Grid view'">
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="list" [matTooltip]="'List view'">
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</div>
@@ -45,64 +63,197 @@
}
</div>
} @else {
<!-- Episodes Grid -->
<div class="episodes-grid">
@for (episode of seasons()[selectedSeason]; track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-card"
(click)="selectEpisode(episode)"
[class.episode-card--watched]="
isEpisodeWatched(episode)
"
[class.episode-card--in-progress]="
isEpisodeInProgress(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo.movie_image"
(error)="
$event.target.src =
'./assets/images/default-episode.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<div class="episode-card__placeholder">
<mat-icon>play_circle_outline</mat-icon>
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (getEpisodePositionText(episode); as time) {
<div class="episode-card__position-text">
{{ time }}
@if (viewMode() === 'grid') {
<!-- Episodes Grid View -->
<div class="episodes-grid">
@for (episode of seasons()[selectedSeason]; track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-card"
(click)="selectEpisode(episode)"
[class.episode-card--watched]="
isEpisodeWatched(episode)
"
[class.episode-card--in-progress]="
isEpisodeInProgress(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo.movie_image"
(error)="
$event.target.src =
'./assets/images/default-episode.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<div class="episode-card__placeholder">
<mat-icon>play_circle_outline</mat-icon>
</div>
}
}
<app-watched-badge
[isWatched]="isEpisodeWatched(episode)"
/>
<div class="episode-card__actions">
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (getEpisodePositionText(episode); as time) {
<div class="episode-card__position-text">
{{ time }}
</div>
}
}
<app-watched-badge
[isWatched]="isEpisodeWatched(episode)"
/>
<div class="episode-card__actions">
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
>
<mat-icon>download</mat-icon>
</button>
}
}
<button
mat-icon-button
class="toggle-watched-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
<div class="episode-card__overlay">
<div class="episode-card__play">
<mat-icon>play_arrow</mat-icon>
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<h4 class="episode-card__title">
{{ episode.episode_num }}. {{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo.duration }}
</span>
}
</div>
</article>
}
</div>
} @else {
<!-- Episodes List View -->
<div class="episodes-list">
@for (episode of seasons()[selectedSeason]; track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-list-item"
(click)="selectEpisode(episode)"
[class.episode-list-item--watched]="isEpisodeWatched(episode)"
[class.episode-list-item--in-progress]="isEpisodeInProgress(episode)"
>
<div class="episode-list-item__number">
{{ episode.episode_num }}
</div>
<div class="episode-list-item__content">
<div class="episode-list-item__header">
<h4 class="episode-list-item__title">
{{ episode.title }}
@if (isEpisodeWatched(episode)) {
<mat-icon class="watched-icon">check_circle</mat-icon>
}
</h4>
<div class="episode-list-item__meta">
@if (episodeInfo?.duration) {
<span class="episode-list-item__duration">
{{ episodeInfo.duration }}
</span>
}
@if (getEpisodePositionText(episode); as time) {
<span class="episode-list-item__progress-text">
{{ time }}
</span>
}
</div>
</div>
@if (episodeInfo?.plot) {
<p class="episode-list-item__description">
{{ episodeInfo.plot }}
</p>
}
@if (getEpisodeProgress(episode) > 0) {
<div class="episode-list-item__progress-bar">
<div
class="episode-list-item__progress-fill"
[style.width.%]="getEpisodeProgress(episode)"
></div>
</div>
}
</div>
<div class="episode-list-item__actions">
<button
mat-icon-button
class="action-btn play-btn"
(click)="selectEpisode(episode); $event.stopPropagation()"
[matTooltip]="'Play'"
>
<mat-icon>play_arrow</mat-icon>
</button>
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
class="action-btn action-btn--success"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
>
<mat-icon>play_circle</mat-icon>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
class="action-btn"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
>
@@ -111,7 +262,7 @@
} @else {
<button
mat-icon-button
class="download-btn"
class="action-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
>
@@ -121,13 +272,14 @@
}
<button
mat-icon-button
class="toggle-watched-btn"
class="action-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
"
[class.action-btn--success]="isEpisodeWatched(episode)"
>
<mat-icon>{{
isEpisodeWatched(episode)
@@ -136,33 +288,10 @@
}}</mat-icon>
</button>
</div>
<div class="episode-card__overlay">
<div class="episode-card__play">
<mat-icon>play_arrow</mat-icon>
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<h4 class="episode-card__title">
{{ episode.episode_num }}. {{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo.duration }}
</span>
}
</div>
</article>
}
</div>
</article>
}
</div>
}
}
</div>
</section>
@@ -9,6 +9,14 @@
justify-content: space-between;
margin-bottom: 20px;
min-height: 32px;
gap: 16px;
&__left {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.section-title {
font-size: 1.25rem;
@@ -44,6 +52,56 @@
height: 18px;
}
}
.view-toggle {
mat-button-toggle-group {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
::ng-deep {
.mat-button-toggle {
background: transparent;
.mat-button-toggle-button {
padding: 0 8px;
height: 32px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--text-secondary);
}
&.mat-button-toggle-checked {
background: rgba(255, 255, 255, 0.1);
mat-icon {
color: var(--accent-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: rgba(255, 255, 255, 0.05);
}
}
.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
line-height: 32px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
}
}
}
}
.loading-container {
@@ -426,3 +484,204 @@
border-radius: 10px;
}
}
// Episodes List View
.episodes-list {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
}
.episode-list-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
.episode-list-item__actions {
opacity: 1;
}
}
&--watched {
opacity: 0.6;
&:hover {
opacity: 1;
}
}
&--in-progress {
border-color: rgba(229, 9, 20, 0.4);
background: rgba(229, 9, 20, 0.05);
&:hover {
border-color: rgba(229, 9, 20, 0.6);
background: rgba(229, 9, 20, 0.08);
}
}
&__number {
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
.watched-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #46d369;
}
}
&__meta {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
&__duration {
font-size: 0.75rem;
color: var(--text-muted);
padding: 2px 8px;
background: rgba(255, 255, 255, 0.06);
border-radius: 10px;
}
&__progress-text {
font-size: 0.75rem;
font-weight: 500;
color: #e50914;
padding: 2px 8px;
background: rgba(229, 9, 20, 0.15);
border-radius: 10px;
}
&__description {
margin: 0;
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
&__progress-fill {
height: 100%;
background: #e50914;
border-radius: 2px;
transition: width 0.3s ease;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: opacity 0.15s ease;
.action-btn {
width: 36px;
height: 36px;
color: var(--text-secondary);
transition: all 0.15s ease;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
&.play-btn {
color: var(--accent-color);
&:hover {
background: rgba(var(--accent-color-rgb), 0.15);
}
}
&--success {
color: #46d369;
&:hover {
background: rgba(70, 211, 105, 0.15);
}
}
&:disabled {
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.5;
}
}
}
// Always show actions on touch devices
@media (hover: none) {
.episode-list-item__actions {
opacity: 1;
}
}
}
@@ -1,6 +1,7 @@
import { KeyValuePipe } from '@angular/common';
import { Component, EventEmitter, Output, input, inject } from '@angular/core';
import { Component, input, inject, output, signal, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -12,6 +13,9 @@ import { ProgressCapsuleComponent } from '../shared/progress-capsule/progress-ca
import { WatchedBadgeComponent } from '../shared/watched-badge/watched-badge.component';
import { DownloadsService } from '../../services/downloads.service';
type EpisodeViewMode = 'grid' | 'list';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
@@ -19,6 +23,7 @@ import { DownloadsService } from '../../services/downloads.service';
imports: [
KeyValuePipe,
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
@@ -27,7 +32,7 @@ import { DownloadsService } from '../../services/downloads.service';
WatchedBadgeComponent,
],
})
export class SeasonContainerComponent {
export class SeasonContainerComponent implements OnInit {
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly downloadsService = inject(DownloadsService);
@@ -39,6 +44,23 @@ export class SeasonContainerComponent {
readonly isElectron = this.downloadsService.isAvailable;
/** Current view mode for episodes (grid or list) */
readonly viewMode = signal<EpisodeViewMode>('grid');
ngOnInit() {
// Load saved view mode preference
const savedMode = localStorage.getItem(EPISODE_VIEW_MODE_KEY) as EpisodeViewMode;
if (savedMode === 'grid' || savedMode === 'list') {
this.viewMode.set(savedMode);
}
}
/** Toggle between grid and list view modes */
setViewMode(mode: EpisodeViewMode) {
this.viewMode.set(mode);
localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode);
}
/**
* Detect if episode is from Stalker portal based on custom_sid marker
*/
@@ -60,27 +82,62 @@ export class SeasonContainerComponent {
return this.xtreamStore.currentPlaylist()?.id;
}
/**
* Get the content ID for playback position lookup.
* For Stalker VOD series, use the generated unique ID (episode.id).
* The API-provided originalId is the same for all episodes (it's the series ID),
* so we use the hash-generated ID which is unique per episode.
* For Xtream episodes, use episode.id directly.
*/
private getEpisodeContentId(episode: XtreamSerieEpisode): number {
const customSid = (episode as any).custom_sid;
if (customSid === 'vod-series') {
// For vod-series, use the generated unique ID (episode.id)
// NOT originalId, which is the same for all episodes (series ID)
return Number(episode.id);
}
if (customSid === 'regular-series') {
// For regular-series, the hashed id is used for tracking
// (regular series don't have individual episode IDs from API)
return Number(episode.id);
}
// For xtream (non-Stalker), use episode.id directly
return Number(episode.id);
}
/**
* Get a stable numeric ID for an episode (used for downloads tracking)
* For regular-series, episode.id contains cmd string, so we extract ID from it
* For vod-series and xtream, episode.id is already numeric
* For Stalker episodes, use originalCmd/originalId; for Xtream use episode.id
*/
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as any).custom_sid;
if (customSid === 'regular-series') {
// For regular-series, episode.id is cmd like "/media/file_123.mpg"
// Extract the numeric part if possible
const cmd = episode.id as unknown as string;
const match = cmd?.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
// For regular-series, use originalCmd (like "/media/file_123.mpg")
const cmd = (episode as any).originalCmd as string;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
// Fallback: hash the cmd string to get a consistent number
return this.hashString(cmd);
}
// Fallback: hash the cmd string to get a consistent number
return this.hashString(cmd || '');
// Final fallback: use the hashed id
return Number(episode.id);
}
// For vod-series and xtream, use the numeric id directly
if (customSid === 'vod-series') {
// For vod-series, use originalId from the API
const originalId = (episode as any).originalId;
const numId = Number(originalId);
return isNaN(numId) ? this.hashString(String(originalId)) : numId;
}
// For xtream (non-Stalker), use the numeric id directly
const numId = Number(episode.id);
return isNaN(numId) ? this.hashString(String(episode.id)) : numId;
}
@@ -98,8 +155,8 @@ export class SeasonContainerComponent {
return Math.abs(hash);
}
@Output() episodeClicked = new EventEmitter<any>();
@Output() seasonSelected = new EventEmitter<string>();
readonly episodeClicked = output<any>();
readonly seasonSelected = output<string>();
selectedSeason: string;
@@ -118,10 +175,23 @@ export class SeasonContainerComponent {
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
const playlistId = this.xtreamStore.currentPlaylist().id;
const playlistId = this.getPlaylistId(episode);
if (!playlistId) {
console.warn('[SeasonContainer] Cannot toggle watched: no playlist ID');
return;
}
// For Stalker episodes, we need to use the content ID that matches
// what the player uses (originalId for vod-series)
const contentId = this.getEpisodeContentId(episode);
const episodeWithCorrectId = {
...episode,
id: String(contentId),
} as XtreamSerieEpisode;
this.xtreamStore.toggleEpisodeWatched(
playlistId,
episode,
episodeWithCorrectId,
this.seriesId()
);
}
@@ -140,26 +210,33 @@ export class SeasonContainerComponent {
}
isEpisodeWatched(episode: XtreamSerieEpisode) {
return this.xtreamStore.isWatched(Number(episode.id), 'episode');
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.isWatched(contentId, 'episode');
}
isEpisodeInProgress(episode: XtreamSerieEpisode) {
return this.xtreamStore.isInProgress(Number(episode.id), 'episode');
const contentId = this.getEpisodeContentId(episode);
const inProgress = this.xtreamStore.isInProgress(contentId, 'episode');
if (inProgress) {
console.log(
`[SeasonContainer] Episode ${episode.title} (contentId=${contentId}, originalId=${(episode as any).originalId}) is IN PROGRESS`
);
}
return inProgress;
}
getEpisodeProgress(episode: XtreamSerieEpisode) {
return this.xtreamStore.getProgressPercent(
Number(episode.id),
'episode'
);
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.getProgressPercent(contentId, 'episode');
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
if (this.isEpisodeWatched(episode)) return null;
const contentId = this.getEpisodeContentId(episode);
const position = this.xtreamStore
.playbackPositions()
.get(`episode_${episode.id}`);
.get(`episode_${contentId}`);
if (!position || !position.positionSeconds) return null;
let seconds = position.positionSeconds;
@@ -258,11 +335,12 @@ export class SeasonContainerComponent {
let cmd: string;
if (customSid === 'vod-series') {
// VOD series episode - construct the cmd using episode id
cmd = `/media/file_${episode.id}.mpg`;
// VOD series episode - use originalId for the cmd
const originalId = (episode as any).originalId;
cmd = `/media/file_${originalId}.mpg`;
} else {
// Regular series - episode.id contains the cmd
cmd = episode.id as unknown as string;
// Regular series - use originalCmd for the cmd
cmd = (episode as any).originalCmd as string;
}
// Resolve the actual stream URL via Stalker API
@@ -1,8 +1,13 @@
.sidebar {
// Width is controlled by the resizable directive
// Default fallback if directive not applied
width: 400px;
min-width: 250px;
max-width: 600px;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
flex-shrink: 0; // Prevent sidebar from shrinking
.sidebar-header {
padding: 16px;
@@ -130,22 +130,16 @@ export function withPlaybackPositions() {
playlistId
);
console.log(
`[withPlaybackPositions] Loaded all positions (${positions.length})`
);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((pos) => {
// Populate playbackPositions map
const key = getPositionKey(
pos.contentType,
pos.contentXtreamId
);
positionsMap.set(key, pos);
// Populate seriesPositions map
if (
pos.contentType === 'episode' &&
pos.seriesXtreamId
@@ -157,10 +151,6 @@ export function withPlaybackPositions() {
}
});
console.log(
`[withPlaybackPositions] Series map populated: ${seriesMap.size} series`
);
patchState(store, {
playbackPositions: positionsMap,
seriesPositions: seriesMap,
@@ -174,13 +164,7 @@ export function withPlaybackPositions() {
const positions = store
.seriesPositions()
.get(seriesXtreamId);
if (positions && positions.length > 0) {
console.log(`[withPlaybackPositions] Series ${seriesXtreamId} HAS progress`);
return true;
}
// console.log(`[withPlaybackPositions] Series ${seriesXtreamId} NO progress`);
return false;
return positions !== undefined && positions.length > 0;
},
/**
@@ -196,11 +180,6 @@ export function withPlaybackPositions() {
'vod'
);
console.log(
`[withPlaybackPositions] Loaded VOD position for ${vodXtreamId}:`,
position
);
if (position) {
const key = getPositionKey('vod', vodXtreamId);
const updated = new Map(store.playbackPositions());
@@ -221,11 +200,6 @@ export function withPlaybackPositions() {
seriesXtreamId
);
console.log(
`[withPlaybackPositions] Loaded series positions for ${seriesXtreamId}:`,
positions
);
const updated = new Map(store.seriesPositions());
updated.set(seriesXtreamId, positions);
patchState(store, { seriesPositions: updated });
@@ -255,10 +229,6 @@ export function withPlaybackPositions() {
data.contentType,
data.contentXtreamId
);
console.log(
`[withPlaybackPositions] Saving position for ${key}:`,
data
);
const updated = new Map(store.playbackPositions());
updated.set(key, data);
patchState(store, { playbackPositions: updated });
@@ -373,18 +343,8 @@ export function withPlaybackPositions() {
if (window.electron?.onPlaybackPositionUpdate) {
unsubscribe = window.electron.onPlaybackPositionUpdate(
(data: any) => {
console.log(
'[withPlaybackPositions] Received update:',
data
);
// Ensure playlistId is present
if (data.playlistId) {
store.savePosition(data.playlistId, data);
} else {
console.warn(
'[withPlaybackPositions] Missing playlistId in update',
data
);
}
}
);
@@ -278,13 +278,20 @@ export function withSelection() {
/**
* Set the selected category
* Only resets page to 0 when category actually changes
*/
setSelectedCategory(categoryId: number | null): void {
patchState(store, {
selectedCategoryId:
categoryId !== null ? Number(categoryId) : null,
page: 0,
});
const newCategoryId =
categoryId !== null ? Number(categoryId) : null;
const currentCategoryId = store.selectedCategoryId();
// Only reset page if category actually changed
if (currentCategoryId !== newCategoryId) {
patchState(store, {
selectedCategoryId: newCategoryId,
page: 0,
});
}
},
/**
@@ -9,10 +9,20 @@ import { XtreamVodDetails } from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { SettingsStore } from '../../services/settings-store.service';
import { XtreamStore } from '../stores/xtream.store';
import { SafePipe } from './safe.pipe';
import { SafePipe } from '@iptvnator/pipes';
/**
* Route-based VOD details container for Xtream.
*
* This is a "smart" component that:
* - Reads route params (vodId, categoryId)
* - Fetches data via XtreamStore
* - Manages playback and favorites
*
* Used for route-based navigation: /xtreams/:id/vod/:categoryId/:vodId
*/
@Component({
templateUrl: './vod-details.component.html',
templateUrl: './vod-details-route.component.html',
styleUrls: ['../detail-view.scss'],
imports: [
ContentHeroComponent,
@@ -23,7 +33,7 @@ import { SafePipe } from './safe.pipe';
MatProgressSpinnerModule,
],
})
export class VodDetailsComponent implements OnInit, OnDestroy {
export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private location = inject(Location);
private settingsStore = inject(SettingsStore);
private route = inject(ActivatedRoute);
@@ -1,4 +1,11 @@
<div class="sidebar">
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
<app-playlist-switcher
class="switcher"
@@ -5,7 +5,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from 'components';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { XtreamCategory } from 'shared-interfaces';
import {
CategoryManagementDialogComponent,
@@ -24,6 +24,7 @@ import { XtreamStore } from './stores/xtream.store';
MatIconButton,
MatTooltipModule,
PlaylistSwitcherComponent,
ResizableDirective,
RouterOutlet,
TranslateModule,
],
@@ -49,26 +49,20 @@ export class XtreamShellComponent {
];
private currentPlaylistId: string | null = null;
private isFirstLoad = true;
constructor() {
// Subscribe to route params to handle switching between playlists
this.route.params.pipe(takeUntilDestroyed()).subscribe(async (params) => {
const newPlaylistId = params['id'];
// Skip if playlist ID hasn't changed (after first load)
if (!this.isFirstLoad && this.currentPlaylistId === newPlaylistId) {
// Skip if playlist ID hasn't changed
if (this.currentPlaylistId === newPlaylistId) {
return;
}
if (this.isFirstLoad) {
// First load - just set the playlist ID without resetting
this.xtreamStore.setPlaylistId(newPlaylistId);
this.isFirstLoad = false;
} else {
// Switching to a different playlist - reset and set new ID
this.xtreamStore.resetStore(newPlaylistId);
}
// Always reset the store when playlist changes to prevent stale data
// from previous sessions showing up (the store is a singleton)
this.xtreamStore.resetStore(newPlaylistId);
this.currentPlaylistId = newPlaylistId;
this.store.dispatch(
@@ -83,13 +77,16 @@ export class XtreamShellComponent {
await this.xtreamStore.checkPortalStatus();
});
effect(
() => {
if (this.xtreamStore.currentPlaylist() !== null) {
this.xtreamStore.initializeContent();
}
},
);
effect(() => {
const playlist = this.xtreamStore.currentPlaylist();
const playlistId = this.xtreamStore.playlistId();
// Only initialize content when playlist is loaded AND matches the current playlistId
// This prevents stale data from a previous session from being used
if (playlist !== null && playlist.id === playlistId) {
this.xtreamStore.initializeContent();
}
});
}
handleCategoryClick(category: 'vod' | 'live' | 'series') {
@@ -53,9 +53,9 @@ export const xtreamRoutes: Route[] = [
{
path: ':categoryId/:vodId',
loadComponent: () =>
import('./vod-details/vod-details.component').then(
(c) => c.VodDetailsComponent
),
import(
'./vod-details/vod-details-route.component'
).then((c) => c.VodDetailsRouteComponent),
},
],
},
@@ -1,65 +1,77 @@
@let meta = normalizedMeta();
<!-- Hero Section with Backdrop -->
<app-content-hero
[title]="item.info.name"
[description]="item.info.description"
[posterUrl]="item.info.movie_image"
[backdropUrl]="item.info.backdrop_path?.[0]"
[title]="meta.title"
[description]="meta.description"
[posterUrl]="meta.posterUrl"
[backdropUrl]="meta.backdropUrl"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-container hero-tags>
@if (item.info?.releasedate) {
<span class="details__tag">{{
item.info.releasedate | slice: 0 : 4
}}</span>
@if (meta.year) {
<span class="details__tag">{{ meta.year }}</span>
}
@if (item.info?.genre) {
<span class="details__tag">{{ item.info.genre }}</span>
@if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
}
@if (item.info?.duration) {
<span class="details__tag">{{ item.info.duration }}</span>
@if (meta.duration) {
<span class="details__tag">{{ meta.duration }}</span>
}
@if (item.info?.rating_imdb) {
@if (meta.ratingImdb) {
<span class="details__tag details__tag--rating">
⭐ {{ item.info.rating_imdb }}
⭐ {{ meta.ratingImdb }}
</span>
}
@if (item.info?.country) {
<span class="details__tag">{{ item.info.country }}</span>
@if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (item.info?.actors) {
@if (meta.actors) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ item.info.actors }}</span>
<span class="value">{{ meta.actors }}</span>
</div>
}
@if (item.info?.director) {
@if (meta.director) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ item.info.director }}</span>
<span class="value">{{ meta.director }}</span>
</div>
}
@if (item.info?.rating_kinopoisk) {
@if (meta.ratingKinopoisk) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.KINOPOISK_RATING' | translate
}}</span>
<span class="value">{{ item.info.rating_kinopoisk }}</span>
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
<span class="value">{{ meta.ratingKinopoisk }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<button class="play-btn" (click)="playClicked.emit(item)">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
@if (!isFavorite) {
<!-- Resume/Play buttons -->
@if (hasPlaybackPosition()) {
<button class="play-btn play-btn--resume" (click)="onResume()">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
</button>
<button class="play-btn play-btn--restart" (click)="onPlay()">
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
} @else {
<button class="play-btn" (click)="onPlay()">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
}
<!-- Favorites button -->
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
@@ -73,19 +85,21 @@
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@if (isElectron) {
@if (isDownloaded) {
<!-- Download buttons (Electron only) -->
@if (isElectron()) {
@if (isDownloaded()) {
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isDownloading) {
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="downloadVod()">
<button class="download-btn" (click)="onDownload()">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
@@ -93,16 +107,13 @@
}
</ng-container>
<!-- YouTube Trailer -->
@if (item.info?.youtube_trailer) {
<!-- YouTube Trailer (Xtream only) -->
@if (meta.youtubeTrailer) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="
'https://www.youtube.com/embed/' +
item.info.youtube_trailer | safe
"
[src]="'https://www.youtube.com/embed/' + meta.youtubeTrailer | safe"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
@@ -112,4 +123,3 @@
</div>
}
</app-content-hero>
@@ -1,23 +1,43 @@
import { Location, SlicePipe } from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnInit,
Output,
computed,
inject,
input,
output,
OnDestroy,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import { PlaylistsService } from 'services';
import { XtreamVodDetails } from 'shared-interfaces';
import { StalkerStore } from '../../stalker/stalker.store';
import { SafePipe } from '../../xtream-tauri/vod-details/safe.pipe';
import {
VodDetailsItem,
normalizeVodDetails,
getVodNumericId,
} from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { SafePipe } from '@iptvnator/pipes';
import { StalkerStore } from '../../stalker/stalker.store';
/**
* Unified VOD details component for both Xtream and Stalker portals.
*
* Uses discriminated union (VodDetailsItem) for type-safe handling.
* All actions are emitted as outputs - parent components handle
* store-specific operations (play, favorites, downloads).
*
* @example
* ```html
* <app-vod-details
* [item]="vodItem"
* [isFavorite]="isFavorite()"
* [playbackPosition]="position()"
* (playClicked)="onPlay($event)"
* (resumeClicked)="onResume($event)"
* (favoriteToggled)="onToggleFavorite($event)"
* />
* ```
*/
@Component({
selector: 'app-vod-details',
templateUrl: './vod-details.component.html',
@@ -27,259 +47,148 @@ import { DownloadsService } from '../../services/downloads.service';
MatIcon,
MatProgressSpinnerModule,
SafePipe,
SlicePipe,
TranslatePipe,
],
})
export class VodDetailsComponent implements OnInit {
@Input({ required: true }) item: XtreamVodDetails;
export class VodDetailsComponent implements OnDestroy {
// ============ Inputs ============
@Output() addToFavoritesClicked = new EventEmitter<any>();
@Output() playClicked = new EventEmitter<XtreamVodDetails>();
@Output() removeFromFavoritesClicked = new EventEmitter<number>();
@Output() backClicked = new EventEmitter<void>();
/** VOD item with discriminated union type */
readonly item = input.required<VodDetailsItem>();
/** Whether this item is in favorites (managed by parent) */
readonly isFavorite = input<boolean>(false);
/** Playback position in seconds for resume feature (managed by parent) */
readonly playbackPosition = input<number | null>(null);
// ============ Outputs ============
/** Emitted when play button is clicked */
readonly playClicked = output<VodDetailsItem>();
/** Emitted when resume button is clicked (includes position) */
readonly resumeClicked = output<{ item: VodDetailsItem; positionSeconds: number }>();
/** Emitted when favorite toggle is clicked */
readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean }>();
/** Emitted when back button is clicked */
readonly backClicked = output<void>();
/** Emitted when download is requested (parent handles URL construction) */
readonly downloadRequested = output<VodDetailsItem>();
// ============ Services ============
private location = inject(Location);
private playlistService = inject(PlaylistsService);
private route = inject(ActivatedRoute);
private stalkerStore = inject(StalkerStore);
private readonly downloadsService = inject(DownloadsService);
private portalId =
this.route.snapshot.paramMap.get('id') ??
this.route.parent.snapshot.params.id;
private readonly stalkerStore = inject(StalkerStore);
/**
* Check if this is a Stalker item
* Uses both cmd property check and stalkerStore playlist to be more reliable
*/
private get isStalker(): boolean {
const hasCmd = !!(this.item as any)?.cmd;
const hasStalkerPlaylist = !!(
this.stalkerStore.currentPlaylist()?.portalUrl &&
this.stalkerStore.currentPlaylist()?.macAddress
);
return hasCmd || hasStalkerPlaylist;
}
// ============ Computed State ============
/**
* Get the correct playlist ID based on mode
* Stalker uses _id, Xtream uses portalId from route
*/
private getPlaylistIdForDownloads(): string | null {
if (this.isStalker) {
return this.stalkerStore.currentPlaylist()?._id || null;
/** Whether running in Electron (downloads available) */
readonly isElectron = computed(() => this.downloadsService.isAvailable());
/** Normalized metadata for display */
readonly normalizedMeta = computed(() => {
return normalizeVodDetails(this.item());
});
/** Whether item is from Stalker portal */
readonly isStalkerItem = computed(() => this.item().type === 'stalker');
/** Whether there's a playback position to resume from */
readonly hasPlaybackPosition = computed(() => {
const pos = this.playbackPosition();
return pos !== null && pos > 0;
});
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
readonly formattedPosition = computed(() => {
const pos = this.playbackPosition();
if (!pos || pos <= 0) return '';
const hours = Math.floor(pos / 3600);
const minutes = Math.floor((pos % 3600) / 60);
const seconds = Math.floor(pos % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return this.portalId;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
/** Whether downloads are available (Electron only) */
get isElectron(): boolean {
return this.downloadsService.isAvailable();
}
/** Whether VOD is already downloaded */
readonly isDownloaded = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
});
isFavorite = false;
isLoading = false;
/** Whether VOD is currently downloading */
readonly isDownloading = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
});
ngOnInit(): void {
this.checkFavoriteStatus();
}
// ============ Lifecycle ============
checkFavoriteStatus() {
this.playlistService
.getPortalFavorites(this.portalId)
.subscribe((favorites) => {
if (!favorites || favorites.length === 0) {
this.isFavorite = false;
} else {
this.isFavorite = favorites.some((i) => {
const hasStreamId =
i?.stream_id !== undefined &&
this.item?.movie_data?.stream_id !== undefined;
const hasId =
(i as any)?.details?.id !== undefined &&
(this.item as any)?.id !== undefined;
return (
(hasStreamId &&
i.stream_id ===
this.item.movie_data.stream_id) ||
(hasId &&
(i as any).details.id === (this.item as any).id)
);
});
}
});
}
toggleFavorite() {
if (this.isFavorite) {
this.removeFromFavoritesClicked.emit(
this.item?.movie_data?.stream_id || (this.item as any)?.id
);
} else {
// stalker mode
if ((this.item as any).cmd) {
this.addToFavoritesClicked.emit({
name: this.item.info.name,
stream_id: (this.item as any).id,
cover: this.item.info.movie_image,
cmd: (this.item as any).cmd || '',
details: this.item,
});
} else {
this.addToFavoritesClicked.emit({
name: this.item.movie_data.name,
stream_id: this.item.movie_data.stream_id,
container_extension:
this.item.movie_data.container_extension,
cover: this.item.info.movie_image,
stream_type: 'movie',
});
}
}
this.isFavorite = !this.isFavorite;
}
goBack() {
this.backClicked.emit();
if (this.isStalker) {
// Stalker mode: clear selectedItem to return to category view
ngOnDestroy(): void {
// Clear selected item when leaving Stalker mode
if (this.isStalkerItem()) {
this.stalkerStore.clearSelectedItem();
} else {
// Xtream mode: use browser history navigation
this.location.back();
}
}
/** Check if VOD is already downloaded */
get isDownloaded(): boolean {
const vodId = this.getVodId();
const playlistId = this.getPlaylistIdForDownloads();
if (!vodId || !playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(vodId, playlistId, 'vod');
// ============ Actions ============
/** Handle play button click */
onPlay(): void {
this.playClicked.emit(this.item());
}
/** Check if VOD is currently downloading */
get isDownloading(): boolean {
const vodId = this.getVodId();
const playlistId = this.getPlaylistIdForDownloads();
if (!vodId || !playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
}
/** Get VOD ID from item */
private getVodId(): number | null {
if (this.isStalker) {
return Number((this.item as any)?.id) || null;
}
return Number(this.item?.movie_data?.stream_id) || null;
}
/** Get stream URL for download */
private getStreamUrl(): string | null {
if (this.isStalker) {
// For Stalker, the stream URL is in the cmd property
return (this.item as any)?.cmd || null;
}
// For Xtream, construct the URL from playlist info
const playlist = this.stalkerStore.currentPlaylist();
if (!playlist) return null;
const serverUrl = playlist.serverUrl?.replace(/\/$/, '') || '';
const username = playlist.username || '';
const password = playlist.password || '';
const streamId = this.item?.movie_data?.stream_id;
const extension = this.item?.movie_data?.container_extension || 'mp4';
if (!streamId) return null;
return `${serverUrl}/movie/${username}/${password}/${streamId}.${extension}`;
}
/** Download VOD */
async downloadVod() {
const vodId = this.getVodId();
if (!vodId) return;
const playlistId = this.getPlaylistIdForDownloads();
if (!playlistId) {
console.error('[VodDetails] No playlist ID available');
return;
}
let url: string;
if (this.isStalker) {
// For Stalker, resolve the actual stream URL via the API
const playlist = this.stalkerStore.currentPlaylist();
const cmd = (this.item as any)?.cmd;
if (!cmd || !playlist?.portalUrl || !playlist?.macAddress) {
console.error('[VodDetails] Missing Stalker data: cmd, portalUrl, or macAddress');
return;
}
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd
);
if (!url) {
console.error('[VodDetails] Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
console.error('[VodDetails] Error resolving Stalker stream URL:', error);
return;
}
await this.downloadsService.startDownload({
playlistId,
xtreamId: vodId,
contentType: 'vod',
title: this.item?.info?.name || 'Unknown',
url,
posterUrl: this.item?.info?.movie_image,
headers: {
userAgent: playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
},
// Playlist info for auto-creation (Stalker playlists)
playlistName: playlist?.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist?.portalUrl,
macAddress: playlist?.macAddress,
});
} else {
// For Xtream, construct the URL from playlist info
const streamUrl = this.getStreamUrl();
if (!streamUrl) return;
url = streamUrl;
await this.downloadsService.startDownload({
playlistId,
xtreamId: vodId,
contentType: 'vod',
title: this.item?.info?.name || 'Unknown',
url,
posterUrl: this.item?.info?.movie_image,
/** Handle resume button click */
onResume(): void {
const pos = this.playbackPosition();
if (pos && pos > 0) {
this.resumeClicked.emit({
item: this.item(),
positionSeconds: pos,
});
}
}
/** Play from local file */
async playFromLocal() {
const vodId = this.getVodId();
const playlistId = this.getPlaylistIdForDownloads();
if (!vodId || !playlistId) return;
/** Handle favorite toggle - emits the desired new state */
toggleFavorite(): void {
this.favoriteToggled.emit({
item: this.item(),
isFavorite: !this.isFavorite(),
});
}
/** Handle back navigation - emit event for parent to handle */
goBack(): void {
this.backClicked.emit();
}
/** Handle download request */
onDownload(): void {
this.downloadRequested.emit(this.item());
}
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const item = this.item();
const vodId = getVodNumericId(item);
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
playlistId,
item.playlistId,
'vod'
);
@@ -56,10 +56,9 @@
}
@case ('vod-details') {
<app-vod-details
[item]="$any(vodDetails)"
(playClicked)="playVod($event)"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($event)"
[item]="getVodDetailsItem()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
/>
}
@case ('serie-details') {
@@ -37,6 +37,8 @@ import {
XtreamSerieDetails,
XtreamSerieEpisode,
XtreamVodDetails,
VodDetailsItem,
createXtreamVodItem,
} from 'shared-interfaces';
import { LiveStreamLayoutComponent } from 'shared-portals';
import { SettingsStore } from '../services/settings-store.service';
@@ -393,6 +395,40 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy {
this.openPlayer(streamUrl, vodItem.info.name);
}
/** Creates a VodDetailsItem for the shared vod-details component */
getVodDetailsItem(): VodDetailsItem {
const playlist = this.currentPlaylist() as Playlist;
return createXtreamVodItem(
this.vodDetails as XtreamVodDetails,
playlist?._id ?? '',
this.contentId
);
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'xtream') {
this.playVod(item.data);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
if (event.item.type === 'xtream') {
if (event.isFavorite) {
this.addToFavorites({
name: event.item.data.movie_data.name,
stream_id: event.item.data.movie_data.stream_id,
container_extension: event.item.data.movie_data.container_extension,
cover: event.item.data.info.movie_image,
stream_type: 'movie',
});
} else {
this.removeFromFavorites(event.item.vodId);
}
}
}
playEpisode(episode: XtreamSerieEpisode) {
const playlist = this.currentPlaylist() as Playlist;
const { serverUrl, username, password, userAgent, referrer, origin } =
+1
View File
@@ -1,5 +1,6 @@
// @import 'themes/';
@import '@yangkghjh/videojs-aspect-ratio-panel/dist/videojs-aspect-ratio-panel.css';
@import '../../../libs/ui/components/src/lib/resizable/resizable.scss';
// macOS specific styles - space for traffic lights
app-root.macos-platform app-header .modern-header {
+10 -2
View File
@@ -300,7 +300,7 @@ export class PlaylistEffects {
}),
map(async (playlist) => {
if (playlist.serverUrl && this.dataService.isElectron) {
// Use Electron database API
// Create Xtream playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlist._id.toString(),
name: playlist.title || '',
@@ -309,8 +309,16 @@ export class PlaylistEffects {
password: playlist.password || '',
type: 'xtream',
});
console.log('Playlist created in database');
this.router.navigate(['/xtreams/', playlist._id]);
} else if (playlist.macAddress && this.dataService.isElectron) {
// Create Stalker playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlist._id.toString(),
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
})
);
+8
View File
@@ -27,3 +27,11 @@ export * from './lib/xtream-serie-details.interface';
export * from './lib/xtream-serie-item.interface';
export * from './lib/xtream-vod-details.interface';
export * from './lib/xtream-vod-stream.interface';
// Stalker interfaces
export * from './lib/stalker-vod-details.interface';
export * from './lib/stalker-serial-details.interface';
// Unified VOD details (discriminated union + adapters)
export * from './lib/vod-details-item.interface';
export * from './lib/vod-details-adapters';
@@ -0,0 +1,28 @@
/**
* Stalker portal series/serial details response structure.
* Contains series metadata from Stalker/Ministra portals.
*/
export interface StalkerSerialDetails {
/** Series title */
name: string;
/** Plot description */
description: string;
/** Director name */
director: string;
/** Comma-separated list of actors */
actors: string;
/** Release year */
year: string;
/** Comma-separated genres */
genres_str: string;
/** Age rating */
age: string;
/** IMDB rating as string */
rating_imdb: string;
/** Kinopoisk rating as string */
rating_kinopoisk: string;
/** Screenshot/poster URL */
screenshot_uri: string;
/** Date added to portal */
added: string;
}
@@ -0,0 +1,35 @@
/**
* Stalker portal VOD details response structure.
* Contains movie metadata from Stalker/Ministra portals.
*/
export interface StalkerVodDetails {
/** Unique identifier for the VOD item */
id: string;
/** Command string used to play the stream (e.g., "/media/file_123.mpg") */
cmd: string;
/** VOD metadata */
info: StalkerVodInfo;
}
export interface StalkerVodInfo {
/** Poster/cover image URL */
movie_image: string;
/** Plot description */
description: string;
/** Formatted/system title (often generic like "video_name_format") */
name: string;
/** Original title (preferred, more descriptive) */
o_name?: string;
/** Comma-separated list of actors */
actors: string;
/** Director name */
director: string;
/** Release date (format varies) */
releasedate: string;
/** Genre(s) */
genre: string;
/** IMDB rating as string */
rating_imdb: string;
/** Kinopoisk rating as string */
rating_kinopoisk: string;
}
@@ -0,0 +1,159 @@
import { XtreamVodDetails } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import {
NormalizedVodMeta,
VodDetailsItem,
XtreamVodItem,
StalkerVodItem,
} from './vod-details-item.interface';
/**
* Normalizes Xtream VOD details into a display-friendly format.
* Handles the various field name variations in Xtream API responses.
*/
export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
const info = item?.info;
const movieData = item?.movie_data;
return {
title: info?.name || movieData?.name || 'Unknown',
description: info?.description || info?.plot,
posterUrl: info?.movie_image || info?.cover_big,
backdropUrl: info?.backdrop_path?.[0],
year: extractYear(info?.releasedate),
genre: info?.genre,
duration: info?.duration || formatDuration(info?.duration_secs),
country: info?.country,
director: info?.director,
actors: info?.actors || info?.cast,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
youtubeTrailer: info?.youtube_trailer,
};
}
/**
* Normalizes Stalker VOD details into a display-friendly format.
* Stalker has fewer metadata fields than Xtream.
* Prefers o_name (original title) over name (often formatted/generic).
*/
export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta {
const info = item?.info;
return {
title: info?.o_name || info?.name || 'Unknown',
description: info?.description,
posterUrl: info?.movie_image,
backdropUrl: undefined, // Stalker doesn't provide backdrop
year: extractYear(info?.releasedate),
genre: info?.genre,
duration: undefined, // Stalker doesn't provide duration
country: undefined, // Stalker doesn't provide country
director: info?.director,
actors: info?.actors,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
youtubeTrailer: undefined, // Stalker doesn't provide trailers
};
}
/**
* Normalizes any VodDetailsItem to display-friendly format.
* Uses discriminated union for type-safe handling.
*/
export function normalizeVodDetails(item: VodDetailsItem): NormalizedVodMeta {
if (item.type === 'xtream') {
return normalizeXtreamVod(item.data);
}
return normalizeStalkerVod(item.data);
}
/**
* Creates an XtreamVodItem from raw data and context.
*/
export function createXtreamVodItem(
data: XtreamVodDetails,
playlistId: string,
vodId?: number
): XtreamVodItem {
return {
type: 'xtream',
data,
playlistId,
vodId: vodId ?? data.movie_data?.stream_id ?? 0,
};
}
/**
* Creates a StalkerVodItem from raw data and context.
*/
export function createStalkerVodItem(
data: StalkerVodDetails,
playlistId: string
): StalkerVodItem {
return {
type: 'stalker',
data,
playlistId,
cmd: data.cmd,
};
}
/**
* Gets the unique identifier for a VOD item.
* Returns vodId for Xtream, id for Stalker.
*/
export function getVodId(item: VodDetailsItem): number | string {
if (item.type === 'xtream') {
return item.vodId;
}
return item.data.id;
}
/**
* Gets the numeric ID for download/favorite tracking.
*/
export function getVodNumericId(item: VodDetailsItem): number {
if (item.type === 'xtream') {
return item.vodId;
}
return Number(item.data.id) || 0;
}
// ============ Helper Functions ============
/**
* Extracts 4-digit year from various date formats.
*/
function extractYear(dateString?: string): string | undefined {
if (!dateString) return undefined;
// Try to extract 4-digit year from beginning
const yearMatch = dateString.match(/^(\d{4})/);
if (yearMatch) {
return yearMatch[1];
}
// Try to parse as date and extract year
const date = new Date(dateString);
if (!isNaN(date.getTime())) {
return date.getFullYear().toString();
}
return dateString.slice(0, 4);
}
/**
* Formats duration from seconds to human-readable string.
*/
function formatDuration(seconds?: number): string | undefined {
if (!seconds || seconds <= 0) return undefined;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
@@ -0,0 +1,96 @@
import { XtreamVodDetails } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
/**
* Discriminated union for VOD details across portal types.
* Enables type-safe handling without runtime type checks.
*
* @example
* ```typescript
* function handleVod(item: VodDetailsItem) {
* if (item.type === 'xtream') {
* // TypeScript knows item.data is XtreamVodDetails
* console.log(item.data.movie_data.stream_id);
* } else {
* // TypeScript knows item.data is StalkerVodDetails
* console.log(item.data.cmd);
* }
* }
* ```
*/
export type VodDetailsItem = XtreamVodItem | StalkerVodItem;
/**
* Xtream Codes VOD item wrapper with context.
*/
export interface XtreamVodItem {
/** Discriminator for Xtream Codes portals */
readonly type: 'xtream';
/** Full VOD details from Xtream API */
data: XtreamVodDetails;
/** Playlist/portal ID for favorites and downloads */
playlistId: string;
/** Stream ID from Xtream API */
vodId: number;
}
/**
* Stalker portal VOD item wrapper with context.
*/
export interface StalkerVodItem {
/** Discriminator for Stalker portals */
readonly type: 'stalker';
/** Full VOD details from Stalker API */
data: StalkerVodDetails;
/** Playlist/portal ID for favorites and downloads */
playlistId: string;
/** Command string for stream playback */
cmd: string;
}
/**
* Normalized metadata for display in templates.
* Provides a consistent interface regardless of portal type.
*/
export interface NormalizedVodMeta {
/** Movie title */
title: string;
/** Plot description */
description?: string;
/** Poster/cover image URL */
posterUrl?: string;
/** Backdrop image URL (Xtream only) */
backdropUrl?: string;
/** Release year (4 digits) */
year?: string;
/** Genre(s) */
genre?: string;
/** Duration string (e.g., "1h 30m") */
duration?: string;
/** Country of origin (Xtream only) */
country?: string;
/** Director name */
director?: string;
/** Actors/cast (comma-separated) */
actors?: string;
/** IMDB rating */
ratingImdb?: string;
/** Kinopoisk rating */
ratingKinopoisk?: string;
/** YouTube trailer ID (Xtream only) */
youtubeTrailer?: string;
}
/**
* Type guard to check if item is Xtream VOD.
*/
export function isXtreamVod(item: VodDetailsItem): item is XtreamVodItem {
return item.type === 'xtream';
}
/**
* Type guard to check if item is Stalker VOD.
*/
export function isStalkerVod(item: VodDetailsItem): item is StalkerVodItem {
return item.type === 'stalker';
}
+1
View File
@@ -19,3 +19,4 @@ export * from './lib/recent-playlists/recent-playlists.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/resizable/resizable.directive';
@@ -0,0 +1,297 @@
import {
Directive,
ElementRef,
Renderer2,
input,
output,
OnInit,
OnDestroy,
effect,
signal,
AfterViewInit,
} from '@angular/core';
/**
* ResizableDirective - Makes an element horizontally resizable by dragging its edge.
*
* Usage:
* ```html
* <div appResizable [minWidth]="200" [maxWidth]="600" [storageKey]="'sidebar-width'">
* Content here
* </div>
* ```
*
* Features:
* - Smooth drag handle with visual feedback
* - Persists width to localStorage
* - Respects min/max width constraints
* - Emits width changes for reactive updates
*/
@Directive({
selector: '[appResizable]',
standalone: true,
})
export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
/** Minimum width in pixels */
readonly minWidth = input<number>(200);
/** Maximum width in pixels */
readonly maxWidth = input<number>(600);
/** LocalStorage key for persisting width. If not set, width won't be persisted. */
readonly storageKey = input<string>('');
/** Default width to use when no stored value exists */
readonly defaultWidth = input<number>(400);
/** Position of the resize handle: 'right' or 'left' */
readonly handlePosition = input<'right' | 'left'>('right');
/** Emits when width changes during resize */
readonly widthChange = output<number>();
/** Emits when resize starts */
readonly resizeStart = output<void>();
/** Emits when resize ends */
readonly resizeEnd = output<number>();
private handleElement: HTMLElement | null = null;
private isResizing = signal(false);
private startX = 0;
private startWidth = 0;
private currentWidth = signal(0);
// Event listener references for cleanup
private boundMouseMove: ((e: MouseEvent) => void) | null = null;
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
constructor(
private readonly el: ElementRef<HTMLElement>,
private readonly renderer: Renderer2
) {
// Apply resizing class when dragging
effect(() => {
if (this.isResizing()) {
this.renderer.addClass(document.body, 'resizing-active');
this.renderer.addClass(this.el.nativeElement, 'is-resizing');
} else {
this.renderer.removeClass(document.body, 'resizing-active');
this.renderer.removeClass(this.el.nativeElement, 'is-resizing');
}
});
}
ngOnInit(): void {
// Load persisted width FIRST to avoid flash of default width
this.loadPersistedWidth();
this.setupStyles();
}
ngAfterViewInit(): void {
this.createResizeHandle();
}
ngOnDestroy(): void {
this.removeGlobalListeners();
if (this.handleElement) {
this.handleElement.remove();
}
}
private setupStyles(): void {
const el = this.el.nativeElement;
// Ensure the element has position relative for handle positioning
const computedStyle = window.getComputedStyle(el);
if (computedStyle.position === 'static') {
this.renderer.setStyle(el, 'position', 'relative');
}
// Add transition for smooth width changes (only when not dragging)
this.renderer.addClass(el, 'resizable-element');
}
private createResizeHandle(): void {
const handle = this.renderer.createElement('div');
this.handleElement = handle;
// Set up handle classes based on position
this.renderer.addClass(handle, 'resize-handle');
this.renderer.addClass(
handle,
`resize-handle--${this.handlePosition()}`
);
// Create inner visual elements for the handle
const handleLine = this.renderer.createElement('div');
this.renderer.addClass(handleLine, 'resize-handle__line');
this.renderer.appendChild(handle, handleLine);
// Create grip dots
const gripDots = this.renderer.createElement('div');
this.renderer.addClass(gripDots, 'resize-handle__grip');
for (let i = 0; i < 3; i++) {
const dot = this.renderer.createElement('span');
this.renderer.appendChild(gripDots, dot);
}
this.renderer.appendChild(handle, gripDots);
// Mouse events
this.renderer.listen(handle, 'mousedown', (e: MouseEvent) =>
this.onMouseDown(e)
);
// Touch events for mobile support
this.renderer.listen(handle, 'touchstart', (e: TouchEvent) =>
this.onTouchStart(e)
);
// Append handle to element
this.renderer.appendChild(this.el.nativeElement, handle);
}
private loadPersistedWidth(): void {
const key = this.storageKey();
let widthToApply = this.defaultWidth();
if (key) {
const stored = localStorage.getItem(key);
if (stored) {
const width = parseInt(stored, 10);
if (!isNaN(width)) {
widthToApply = this.clampWidth(width);
}
}
}
// Apply width immediately without transition to avoid flash
this.setWidthImmediate(widthToApply);
}
/** Set width immediately without CSS transition */
private setWidthImmediate(width: number): void {
this.currentWidth.set(width);
// Temporarily disable transition for immediate application
this.renderer.setStyle(this.el.nativeElement, 'transition', 'none');
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
// Re-enable transition after a frame
requestAnimationFrame(() => {
this.renderer.removeStyle(this.el.nativeElement, 'transition');
});
}
private onMouseDown(e: MouseEvent): void {
e.preventDefault();
e.stopPropagation();
this.startResize(e.clientX);
this.boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
this.boundMouseUp = (event: MouseEvent) => this.onMouseUp(event);
document.addEventListener('mousemove', this.boundMouseMove);
document.addEventListener('mouseup', this.boundMouseUp);
}
private onTouchStart(e: TouchEvent): void {
if (e.touches.length !== 1) return;
e.preventDefault();
e.stopPropagation();
this.startResize(e.touches[0].clientX);
this.boundTouchMove = (event: TouchEvent) => this.onTouchMove(event);
this.boundTouchEnd = (event: TouchEvent) => this.onTouchEnd(event);
document.addEventListener('touchmove', this.boundTouchMove, {
passive: false,
});
document.addEventListener('touchend', this.boundTouchEnd);
}
private startResize(clientX: number): void {
this.isResizing.set(true);
this.startX = clientX;
this.startWidth = this.el.nativeElement.offsetWidth;
this.resizeStart.emit();
}
private onMouseMove(e: MouseEvent): void {
if (!this.isResizing()) return;
this.resize(e.clientX);
}
private onTouchMove(e: TouchEvent): void {
if (!this.isResizing() || e.touches.length !== 1) return;
e.preventDefault();
this.resize(e.touches[0].clientX);
}
private resize(clientX: number): void {
const delta =
this.handlePosition() === 'right'
? clientX - this.startX
: this.startX - clientX;
const newWidth = this.clampWidth(this.startWidth + delta);
this.setWidth(newWidth);
this.widthChange.emit(newWidth);
}
private onMouseUp(_e: MouseEvent): void {
this.endResize();
}
private onTouchEnd(_e: TouchEvent): void {
this.endResize();
}
private endResize(): void {
if (!this.isResizing()) return;
this.isResizing.set(false);
this.removeGlobalListeners();
this.persistWidth();
this.resizeEnd.emit(this.currentWidth());
}
private removeGlobalListeners(): void {
if (this.boundMouseMove) {
document.removeEventListener('mousemove', this.boundMouseMove);
this.boundMouseMove = null;
}
if (this.boundMouseUp) {
document.removeEventListener('mouseup', this.boundMouseUp);
this.boundMouseUp = null;
}
if (this.boundTouchMove) {
document.removeEventListener('touchmove', this.boundTouchMove);
this.boundTouchMove = null;
}
if (this.boundTouchEnd) {
document.removeEventListener('touchend', this.boundTouchEnd);
this.boundTouchEnd = null;
}
}
private clampWidth(width: number): number {
return Math.max(this.minWidth(), Math.min(this.maxWidth(), width));
}
private setWidth(width: number): void {
this.currentWidth.set(width);
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
}
private persistWidth(): void {
const key = this.storageKey();
if (key) {
localStorage.setItem(key, this.currentWidth().toString());
}
}
}
@@ -0,0 +1,184 @@
/**
* Resizable Directive Styles
*
* A refined, subtle resize handle that provides clear affordance
* without being visually intrusive. Uses a vertical grip pattern
* that becomes more prominent on hover/active states.
*/
// Prevent text selection during resize
body.resizing-active {
user-select: none !important;
cursor: col-resize !important;
* {
cursor: col-resize !important;
}
}
// Base element styles
.resizable-element {
// Smooth transition when not actively resizing
&:not(.is-resizing) {
transition: width 0.15s ease-out;
}
&.is-resizing {
transition: none;
}
}
// Resize handle base styles
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 12px;
z-index: 100;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
// Visual feedback on hover/active
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 4px;
background: transparent;
transition: background-color 0.2s ease;
border-radius: 2px;
}
&:hover::before,
&:active::before {
background: rgba(255, 255, 255, 0.08);
}
&:active::before {
background: rgba(255, 255, 255, 0.12);
}
// Position variants
&--right {
right: -6px;
&::before {
left: 50%;
transform: translateX(-50%);
}
}
&--left {
left: -6px;
&::before {
right: 50%;
transform: translateX(50%);
}
}
// The main visible line
&__line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(255, 255, 255, 0.12);
transition: all 0.2s ease;
}
&:hover &__line {
width: 2px;
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
}
&:active &__line {
width: 2px;
background: var(--mdc-theme-primary, #6366f1);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
}
// Grip dots indicator
&__grip {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transition: all 0.15s ease;
}
}
&:hover &__grip {
opacity: 1;
span {
background: rgba(255, 255, 255, 0.6);
}
}
&:active &__grip {
opacity: 1;
span {
background: var(--mdc-theme-primary, #6366f1);
transform: scale(1.2);
}
}
}
// Light theme adjustments (if needed)
.light-theme {
.resize-handle {
&::before {
background: transparent;
}
&:hover::before,
&:active::before {
background: rgba(0, 0, 0, 0.04);
}
&:active::before {
background: rgba(0, 0, 0, 0.08);
}
&__line {
background: rgba(0, 0, 0, 0.12);
}
&:hover &__line {
background: rgba(0, 0, 0, 0.25);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.05);
}
&:active &__line {
background: var(--mdc-theme-primary, #6366f1);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
}
&__grip {
span {
background: rgba(0, 0, 0, 0.3);
}
}
&:hover &__grip {
span {
background: rgba(0, 0, 0, 0.5);
}
}
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './lib/filter.pipe';
export * from './lib/moment-date.pipe';
export * from './lib/safe.pipe';
export * from './lib/sort.pipe';
@@ -5,7 +5,14 @@
: 'itv-container-epg'
"
>
<div class="sidebar">
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-content">
<div class="search-panel">
<mat-form-field class="search-bar">
@@ -6,7 +6,8 @@
.itv-container {
display: grid;
grid-template-columns: 330px 1fr 1fr;
// Use auto for sidebar to respect the directive's width
grid-template-columns: auto 1fr 1fr;
grid-template-rows: 50% 50%;
gap: 0 0;
grid-auto-flow: row;
@@ -18,7 +19,8 @@
.itv-container-epg {
display: grid;
grid-template-columns: 330px 1fr 1fr;
// Use auto for sidebar to respect the directive's width
grid-template-columns: auto 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 0 0;
grid-auto-flow: row;
@@ -29,7 +31,18 @@
height: 100%;
}
.sidebar { grid-area: sidebar; }
.sidebar {
grid-area: sidebar;
// Default width (controlled by resizable directive)
width: 400px;
min-width: 250px;
max-width: 600px;
flex-shrink: 0;
position: relative;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
}
.video-player { grid-area: video-player; }
@@ -66,7 +79,7 @@
}
mat-list-option {
width: 320px;
width: 100%;
}
::ng-deep {
@@ -15,6 +15,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { FilterPipe } from '@iptvnator/pipes';
import { TranslateModule } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { EpgItem, VideoPlayer, XtreamItem } from 'shared-interfaces';
import { EpgViewComponent } from '../epg-view/epg-view.component';
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
@@ -32,6 +33,7 @@ import { WebPlayerViewComponent } from '../web-player-view/web-player-view.compo
MatInputModule,
MatFormFieldModule,
NgIf,
ResizableDirective,
ScrollingModule,
WebPlayerViewComponent,
TranslateModule,