refactor: refactor existing vod-details component and implement VOD details route component

- Added `VodDetailsRouteComponent` to handle VOD details with route parameters.
- Refactored existing `VodDetailsComponent` to use a unified structure for Xtream and Stalker portals.
- Enhanced VOD details display with normalized metadata and improved playback position handling.
- Integrated resizable sidebar functionality in main container and live stream layout components.
- Updated routing to use the new VOD details route component.
- Improved favorite toggling and download handling in VOD details.
- Adjusted styles for responsive sidebar and grid layouts.
This commit is contained in:
4gray committed 2026-01-30 20:39:18 +01:00
1 parent e4be046194
commit f95ac9c34e
45 files changed
+1670 -725

No files matched your search

@@ -117,6 +117,9 @@ ipcMain.handle(
'DB_GET_SERIES_PLAYBACK_POSITIONS',
async (event, playlistId: string, seriesXtreamId: number) => {
try {
console.log(
`[DatabaseEvents] Getting series playback positions: playlistId=${playlistId}, seriesXtreamId=${seriesXtreamId}`
);
const db = await getDatabase();
const result = await db
.select()
@@ -132,6 +135,15 @@ ipcMain.handle(
)
);
console.log(
`[DatabaseEvents] Found ${result.length} positions for series ${seriesXtreamId}:`,
result.map((r) => ({
contentXtreamId: r.contentXtreamId,
seriesXtreamId: r.seriesXtreamId,
position: r.positionSeconds,
}))
);
return result;
} catch (error) {
console.error('Error getting series playback positions:', error);
@@ -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,
],
})
@@ -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;
@@ -133,6 +134,27 @@ export class RecentlyViewedComponent {
this.createLinkToPlayVodItv(item.cmd, 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.removeFromFavorites(event.item.data.id);
} else {
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(),
});
}
}
}
/** Handle back from vod-details component */
onVodBack(): void {
this.itemDetails = null;
this.vodDetailsItem = null;
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
@@ -158,4 +214,32 @@ 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.name || item.title || 'Unknown',
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;
@@ -136,6 +140,26 @@ export class StalkerFavoritesComponent {
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;
@@ -169,6 +217,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 +233,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() &&
@@ -8,7 +8,7 @@ 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';
@@ -18,7 +18,7 @@ import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe } 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,6 +41,7 @@ import { StalkerStore } from './stalker.store';
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
ResizableDirective,
RouterOutlet,
TranslatePipe,
],
@@ -1,19 +1,14 @@
@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 (vodDetailsItem) {
<app-vod-details
[item]="vodDetailsItem"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
@@ -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: () => ({
@@ -153,6 +157,12 @@ export class StalkerSearchComponent {
switch (this.selectedFilterType()) {
case 'vod':
this.stalkerStore.setSelectedContentType('vod');
// Create VodDetailsItem for the component
const playlist = this.currentPlaylist();
this.vodDetailsItem = createStalkerVodItem(
this.itemDetails as StalkerVodDetails,
playlist?._id ?? ''
);
break;
case 'series':
this.stalkerStore.setSelectedContentType('series');
@@ -162,6 +172,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.removeFromFavorites(event.item.data.id);
} else {
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(),
});
}
}
}
/** 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) {
@@ -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
);
}
+20 -6
View File
@@ -891,11 +891,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 +930,7 @@ export const StalkerStore = signalStore(
this.currentPlaylist().portalUrl,
this.currentPlaylist().macAddress,
cmdToUse,
episode
episodeNum
);
this.addToRecentlyViewed({
...item,
@@ -929,11 +940,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 +958,8 @@ export const StalkerStore = signalStore(
playlist?.userAgent,
playlist?.referrer,
playlist?.origin,
contentInfo
contentInfo,
startTime
);
} catch (error) {
console.error(
@@ -8,20 +8,14 @@
<!-- 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()!"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
@@ -3,6 +3,11 @@ 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,
} 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';
@@ -81,6 +86,20 @@ 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 ?? ''
);
});
seasons = [];
ngOnInit() {
@@ -184,4 +203,40 @@ 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 favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
if (event.item.type === 'stalker') {
if (event.isFavorite) {
this.removeFromFavorites(event.item.data.id);
} else {
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(),
});
}
}
}
/** Handle back from vod-details component */
onVodBack(): void {
this.store.setSelectedItem(null);
}
}
@@ -1,7 +1,11 @@
:host {
width: 100%;
}
.downloads {
display: flex;
flex-direction: column;
height: 100%;
height: calc(100vh - 20px);
padding: 1rem;
overflow: hidden;
@@ -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, EventEmitter, Output, input, inject, 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;
}
@@ -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;
@@ -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,44 @@
import { Location, SlicePipe } from '@angular/common';
import { Location } 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 +48,151 @@ 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>();
private location = inject(Location);
private playlistService = inject(PlaylistsService);
private route = inject(ActivatedRoute);
private stalkerStore = inject(StalkerStore);
/** 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 readonly location = inject(Location);
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 = 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 */
toggleFavorite(): void {
this.favoriteToggled.emit({
item: this.item(),
isFavorite: this.isFavorite(),
});
}
/** Handle back navigation */
goBack(): void {
this.backClicked.emit();
// Also handle navigation internally for convenience
this.location.back();
}
/** 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.removeFromFavorites(event.item.vodId);
} else {
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',
});
}
}
}
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 {
@@ -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,