refactor: remove legacy xtream portal view

- Deleted SeasonContainerComponent and its associated styles.
- Removed SerialDetailsComponent and its template, including the season container usage.
- Eliminated VodDetailsComponent and its template, along with related logic.
- Cleaned up XtreamMainContainerComponent by removing references to deleted components.
This commit is contained in:
4gray committed 2026-02-24 19:12:23 +01:00
1 parent 27bcc9d059
commit a9be475ce1
26 files changed
-2422

No files matched your search

@@ -1,9 +0,0 @@
import { StalkerPortalActions, XtreamCodeActions } from 'shared-interfaces';
export type PortalActions = StalkerPortalActions | XtreamCodeActions;
export interface Breadcrumb {
action: PortalActions;
title: string;
category_id?: string;
}
@@ -1,61 +0,0 @@
@if (items?.length > 0) {
<div class="grid">
@for (
item of items
| filterBy: searchPhrase() : 'name'
| sortBy: sortType();
track $index
) {
@let imgSrc = item.stream_icon || item.cover || item.logo;
<mat-card (click)="itemClicked.emit(item)">
<mat-card-content>
@if (imgSrc) {
<img
class="stream-icon"
[src]="imgSrc"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
loading="lazy"
alt="channel logo"
/>
} @else {
<div class="stream-icon-placeholder"></div>
}
@if (item.rating || item.rating_imdb) {
<div
class="rating"
[matTooltip]="'XTREAM.IMDB_RATING' | translate"
>
<mat-icon>star</mat-icon
>{{ item.rating || item.rating_imdb }}
</div>
}
</mat-card-content>
<mat-card-actions>
<div class="title">{{ item.name || 'No name' }}</div>
</mat-card-actions>
</mat-card>
}
@if (!(items | filterBy: searchPhrase() : 'name')?.length) {
<app-playlist-error-view
title="No results"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
}
</div>
} @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION' | translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
}
@@ -1,84 +0,0 @@
:host {
overflow-y: auto;
padding: 10px;
}
mat-card {
overflow: hidden;
transition: all 0.9s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: none;
}
mat-card:hover {
box-shadow: 0 14px 28px rgb(0 0 0 / 25%), 0 10px 10px rgb(0 0 0 / 22%);
}
.grid {
display: flex;
flex-wrap: wrap;
gap: 30px;
justify-content: center;
mat-card {
cursor: pointer;
width: 170px;
// height: 300px;
mat-card-content {
padding: 0;
margin: 0;
}
.stream-icon {
width: 100%;
position: relative !important;
/* max-height: 300px; */
display: block;
}
.stream-icon-placeholder {
width: 100%;
background: #999;
max-height: 300px;
min-height: 255px;
display: flex;
justify-content: center;
align-items: center;
&::after {
content: 'No cover';
}
}
.title {
word-break: break-all;
/* padding: 8px; */
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
.rating {
position: absolute;
top: 5px;
right: 6px;
background-color: #3bb33b;
color: #fff;
font-size: 0.8em;
border-radius: 3px;
display: flex;
justify-content: center;
align-items: center;
padding: 0 5px;
mat-icon {
font-size: 16px;
height: 18px;
width: 18px;
}
}
@@ -1,32 +0,0 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FilterPipe, SortPipe } from '@iptvnator/pipes';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamItem } from 'shared-interfaces';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { PortalStore } from '../portal.store';
@Component({
selector: 'app-category-content-view',
templateUrl: './category-content-view.component.html',
styleUrls: ['./category-content-view.component.scss'],
imports: [
FilterPipe,
SortPipe,
MatCardModule,
MatIconModule,
MatTooltipModule,
PlaylistErrorViewComponent,
TranslateModule,
],
})
export class CategoryContentViewComponent {
@Input({ required: true }) items: XtreamItem[];
@Output() itemClicked = new EventEmitter<XtreamItem>();
portalStore = inject(PortalStore);
searchPhrase = this.portalStore.searchPhrase;
sortType = this.portalStore.sortType;
}
@@ -1,23 +0,0 @@
:host {
overflow-y: auto;
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
mat-card-content {
overflow-wrap: break-word;
}
.category-item {
cursor: pointer;
width: 200px;
}
@media (max-width: 800px) {
.category-item {
width: 100%;
}
}
@@ -1,71 +0,0 @@
import { Component, inject, input, output } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { FilterPipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { XtreamCategory } from 'shared-interfaces';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { PortalStore } from '../portal.store';
@Component({
selector: 'app-category-view',
template: `
@if (items().length > 0) {
@for (
item of items() | filterBy: searchPhrase() : 'category_name';
track $index
) {
<mat-card
appearance="outlined"
class="category-item"
(click)="categoryClicked.emit(item)"
>
<mat-card-content>
{{
item.category_name ||
item.name ||
'No category name'
}}
</mat-card-content>
</mat-card>
}
@if (
!(items() | filterBy: searchPhrase() : 'category_name')?.length
) {
<app-playlist-error-view
title="No results"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
}
} @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION' | translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
}
`,
styleUrl: './category-view.component.scss',
imports: [
FilterPipe,
MatCardModule,
MatIconModule,
PlaylistErrorViewComponent,
TranslatePipe,
],
})
export class CategoryViewComponent {
readonly items = input.required<XtreamCategory[]>();
readonly categoryClicked = output<XtreamCategory>();
private readonly portalStore = inject(PortalStore);
readonly searchPhrase = this.portalStore.searchPhrase;
}
@@ -1,7 +0,0 @@
import { ContentType } from './content-type.enum';
export interface ContentTypeNavigationItem {
contentType: ContentType;
label: string;
icon: string;
}
@@ -1,8 +0,0 @@
export enum ContentType {
STB = 'stb',
VODS = 'vod',
SERIES = 'series',
ITV = 'itv',
FAVORITES = 'favorites',
/* RADIO = 'radio', */
}
-153
View File
@@ -1,153 +0,0 @@
:host {
width: 100%;
display: flex;
flex-direction: column;
}
.details {
overflow: visible;
h2 {
word-wrap: break-word;
}
}
.container {
display: grid;
grid-gap: 10px;
grid-template-columns: 320px 1fr;
padding: 0 10px;
overflow: visible;
&::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-size: cover;
z-index: -1;
background-repeat: no-repeat;
}
}
.seasons {
padding: 20px;
.episodes {
margin-bottom: 10px;
button {
margin-top: 5px;
}
}
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 10px 0;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
}
.expand-icon {
font-size: 12px;
margin-right: 10px;
}
}
.seasons-loading,
.episodes-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;
}
.no-episodes {
font-style: italic;
opacity: 0.7;
}
}
.image {
width: 300px;
padding: 20px 10px;
.placeholder-cover {
width: 300px;
height: 500px;
background-color: #999;
border-radius: 5px;
&::after {
content: 'No cover';
position: relative;
top: 230px;
left: 115px;
}
}
}
.image img {
max-width: 100%;
height: auto;
border-radius: 5px;
width: 300px;
}
@media (max-width: 800px) {
.container {
display: flex;
flex-wrap: wrap;
margin: 0 auto;
max-width: 1200px;
}
.image,
.details {
flex: 1 1 100%;
display: flex;
flex-direction: column;
padding: 0;
img {
border-radius: 0;
}
}
.details {
padding: 0 10px;
}
.youtube-trailer {
width: 95% !important;
}
}
.label {
font-weight: 100;
font-size: 0.9em;
padding: 5px 0;
text-decoration: underline;
opacity: 0.6;
}
.action-buttons {
margin-top: 10px;
}
.youtube-trailer {
display: flex;
flex-wrap: wrap;
align-self: center;
width: 650px;
}
@@ -1,119 +0,0 @@
<div id="navigation">
<button mat-button routerLink="/">
<mat-icon>home</mat-icon>
<span class="button-label">{{ 'PLAYLISTS' | translate }}</span>
</button>
@if (showCategories) {
<mat-button-toggle-group
name="contentType"
aria-label="Content Type"
[value]="contentType"
(change)="changeContentType($event.value)"
hideSingleSelectionIndicator
>
@for (
item of contentTypeNavigationItems; track trackByValue($index,
item)) {
<mat-button-toggle
[value]="item.contentType"
>
<mat-icon class="icon">{{ item.icon }}</mat-icon>
<span class="label">{{ item.label }}</span>
</mat-button-toggle>
}
</mat-button-toggle-group>
}
<div>
<button mat-button (click)="openPlaylistDetails()">
<mat-icon>info</mat-icon>
<span class="button-label">{{ 'Info' | translate }}</span>
</button>
<button mat-button (click)="favoritesClicked.emit()">
<mat-icon>star</mat-icon>
<span class="button-label">{{
'CHANNELS.FAVORITES' | translate
}}</span>
</button>
<button mat-button (click)="openSettings()">
<mat-icon>settings</mat-icon>
<span class="button-label">{{ 'MENU.SETTINGS' | translate }}</span>
</button>
</div>
</div>
<div id="sub-panel">
<div class="breadcrumbs">
@for (item of breadcrumbs; track item; let i = $index) {
<div
(click)="processBreadcrumbClick(item)"
>
<span>{{ item.title }}</span>
@if (i !== breadcrumbs.length - 1) {
<mat-icon
>chevron_right</mat-icon
>
}
</div>
}
</div>
<div class="search-container">
<div class="favorite">
@if (favoriteVisible && activeLiveStream) {
<button
mat-icon-button
(click)="clickFavorites()"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{ isFavoriteStream ? 'star' : 'star_outline' }}
</mat-icon>
</button>
}
</div>
@if (sortVisible) {
<div class="sort">
<button
mat-icon-button
[matTooltip]="'CHANNELS.SORT_BY' | translate"
[matMenuTriggerFor]="sortPlaylistMenu"
data-test-id="sort-playlist"
>
<mat-icon>sort</mat-icon>
</button>
</div>
}
@if (searchVisible) {
<div class="search">
<mat-form-field class="search-input">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
placeholder="Search by name"
type="search"
[ngModel]="searchPhrase()"
(ngModelChange)="searchPhraseUpdate.next($event)"
/>
</mat-form-field>
</div>
}
</div>
<mat-menu #sortPlaylistMenu="matMenu">
<section (click)="$event.stopPropagation()">
<button mat-menu-item (click)="setSortType('date')">
<mat-icon>calendar_today</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_DATE' | translate }}</span>
</button>
<button mat-menu-item (click)="setSortType('rating')">
<mat-icon>star</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_RATING' | translate }}</span>
</button>
<button mat-menu-item (click)="setSortType('alpha')">
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_NAME' | translate }}</span>
</button>
</section>
</mat-menu>
</div>
@@ -1,108 +0,0 @@
@use '@angular/material' as mat;
:host {
position: relative;
}
#navigation {
height: 70px;
background: white;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
app-region: drag;
button,
mat-button-toggle-group {
app-region: no-drag;
}
}
#sub-panel {
background: white;
border-bottom: 1px solid #ccc;
padding: 0 1em;
display: flex;
justify-content: space-between;
align-items: center;
height: 40px;
.breadcrumbs {
display: flex;
div {
display: flex;
align-items: center;
&:not(:last-child)::after {
display: none;
}
span:hover {
text-decoration: underline;
cursor: pointer;
}
}
}
.search-container {
display: flex;
}
.search {
text-align: center;
}
}
.search-input {
@include mat.form-field-density(-5);
}
/* stylelint-disable-next-line no-descending-specificity */
::ng-deep {
.search-input {
div {
border-radius: 20px;
}
}
.dark-theme {
#navigation,
#sub-panel {
background: #1b1c1c;
}
#sub-panel {
border-bottom: 1px solid #333;
}
}
div[matformfieldlineripple] {
display: none;
}
}
@media (max-width: 800px) {
.button-label {
display: none;
}
#sub-panel {
height: auto;
padding: 10px 1em;
.search > .search-input {
width: 160px;
}
}
.breadcrumbs {
flex-direction: column;
}
.label {
display: none;
}
}
@@ -1,172 +0,0 @@
import {
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
inject,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistInfoComponent } from 'components';
import { selectCurrentPlaylist } from 'm3u-state';
import {
Observable,
Subject,
debounceTime,
distinctUntilChanged,
map,
} from 'rxjs';
import { XtreamLiveStream } from 'shared-interfaces';
import { SettingsComponent } from '../../settings/settings.component';
import { SettingsStore } from '../../services/settings-store.service';
import { Breadcrumb } from '../breadcrumb.interface';
import { ContentTypeNavigationItem } from '../content-type-navigation-item.interface';
import { ContentType } from '../content-type.enum';
import { PortalStore } from '../portal.store';
@Component({
selector: 'app-navigation-bar',
templateUrl: './navigation-bar.component.html',
styleUrls: ['./navigation-bar.component.scss'],
imports: [
FormsModule,
MatButtonModule,
MatButtonToggleModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
RouterLink,
TranslateModule,
MatMenuModule,
MatCheckboxModule,
MatTooltipModule,
],
})
export class NavigationBarComponent implements OnChanges {
@Input({ required: true }) breadcrumbs: Breadcrumb[];
@Input({ required: true }) contentType: ContentType;
@Input() searchVisible = true;
@Input() sortVisible = false;
@Input() contentTypeNavigationItems: ContentTypeNavigationItem[];
@Input() clientSideSearch = true;
@Input() showCategories = false;
@Input() activeLiveStream!: XtreamLiveStream;
@Input() favoritesLiveStream$: Observable<any>;
@Input() favoriteVisible = false;
@Output() contentTypeChanged = new EventEmitter<ContentType>();
@Output() breadcrumbClicked = new EventEmitter<Breadcrumb>();
@Output() favoritesClicked = new EventEmitter<void>();
@Output() searchTextChanged = new EventEmitter<string>();
@Output() addToFavoritesClicked = new EventEmitter<any>();
@Output() removeFromFavoritesClicked = new EventEmitter<number>();
ContentTypeEnum = ContentType;
dialog = inject(MatDialog);
portalStore = inject(PortalStore);
store = inject(Store);
searchPhrase = this.portalStore.searchPhrase;
searchPhraseUpdate = new Subject<string>();
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
sortType = this.portalStore.sortType;
isFavoriteStream = false;
settingsStore = inject(SettingsStore);
constructor() {
this.searchPhraseUpdate
.pipe(debounceTime(600), distinctUntilChanged())
.subscribe((value) => {
this.setSearchText(value);
});
}
openPlaylistDetails() {
this.dialog.open(PlaylistInfoComponent, {
data: this.currentPlaylist(),
});
}
processBreadcrumbClick(item: Breadcrumb) {
this.setSearchText('');
this.breadcrumbClicked.emit(item);
}
setSortType(type: string) {
this.portalStore.setSortType(type);
}
setSearchText(text: string) {
if (this.clientSideSearch) this.portalStore.setSearchPhrase(text);
else this.searchTextChanged.emit(text);
}
changeContentType(type: ContentType) {
this.setSearchText('');
this.contentTypeChanged.emit(type);
}
trackByValue(_i: number, value: ContentTypeNavigationItem) {
return value.contentType;
}
clickFavorites(): void {
const item = this.activeLiveStream;
if (!this.isFavoriteStream) {
this.addToFavoritesClicked.emit({
name: item.name,
stream_id: item.stream_id,
cover: item.stream_icon,
stream_type: 'live',
});
this.isFavoriteStream = true;
} else {
this.removeFromFavoritesClicked.emit(item.stream_id);
this.isFavoriteStream = false;
}
}
ngOnChanges(changes: SimpleChanges) {
if (changes.activeLiveStream && this.activeLiveStream) {
this.checkFavorite();
}
}
checkFavorite() {
// if activeLiveStream.stream_id include in favorites return the true
const activeLiveStream = this.activeLiveStream;
this.favoritesLiveStream$
.pipe(
map((favorites) =>
favorites.some(
(fav) => fav.stream_id === activeLiveStream.stream_id
)
)
)
.subscribe((isFavorite) => {
this.isFavoriteStream = isFavorite;
});
}
openSettings(): void {
this.dialog.open(SettingsComponent, {
width: '1200px',
maxWidth: '96vw',
maxHeight: '92vh',
data: { isDialog: true },
});
}
}
@@ -1,35 +0,0 @@
<div class="expired-account">
<div class="image-container">
@if (viewType === 'ERROR') {
<img src="./assets/images/broken-tv.svg" />
} @else if (viewType === 'EMPTY_CATEGORY') {
<img
style="max-width: 350px"
src="./assets/images/empty-category.svg"
/>
} @else if (viewType === 'NO_SEARCH_RESULTS') {
<mat-icon class="icon">search</mat-icon>
}
</div>
<div>
<h3>{{ title }}</h3>
{{ description }}
</div>
@if (showActionButtons) {
<div class="actions">
<button mat-stroked-button routerLink="/">
<mat-icon>home</mat-icon>
{{ 'PORTALS.ERROR_VIEW.HOME' | translate }}
</button>
<button mat-stroked-button (click)="openPlaylistDetails()">
<mat-icon>settings</mat-icon>
{{ 'PORTALS.ERROR_VIEW.PLAYLIST_SETTINGS' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="removeClicked()">
<mat-icon>delete</mat-icon>
{{ 'PORTALS.ERROR_VIEW.DELETE' | translate }}
</button>
</div>
}
</div>
@@ -1,43 +0,0 @@
.expired-account {
display: flex;
flex-direction: column;
padding: 10px;
align-items: center;
gap: 10px;
text-align: center;
img {
filter: invert(80%);
}
}
.image-container {
max-width: 400px;
height: auto;
img {
width: 100%;
}
}
.actions {
button {
margin: 5px;
}
}
.icon {
font-size: 64px;
height: 64px;
width: 64px;
}
::ng-deep {
.dark-theme {
.expired-account {
img {
filter: invert(10%) !important;
}
}
}
}
@@ -1,58 +0,0 @@
import { Component, Input, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService, PlaylistInfoComponent } from 'components';
import { PlaylistActions } from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
/**
* @deprecated: there is another one in xtream-electron
*/
@Component({
selector: 'app-playlist-error-view',
templateUrl: './playlist-error-view.component.html',
styleUrls: ['./playlist-error-view.component.scss'],
imports: [MatButtonModule, MatIconModule, RouterLink, TranslateModule],
})
export class PlaylistErrorViewComponent {
readonly dialog = inject(MatDialog);
readonly dialogService = inject(DialogService);
readonly router = inject(Router);
readonly store = inject(Store);
readonly translate = inject(TranslateService);
readonly currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
@Input() description: string;
@Input() showActionButtons = true;
@Input() title: string;
@Input() viewType: 'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS' =
'ERROR';
openPlaylistDetails() {
this.dialog.open(PlaylistInfoComponent, {
data: this.currentPlaylist(),
});
}
removeClicked(): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: (): void =>
this.removePlaylist((this.currentPlaylist() as Playlist)._id),
});
}
removePlaylist(playlistId: string): void {
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
this.router.navigate(['/']);
}
}
-86
View File
@@ -1,86 +0,0 @@
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
export interface PortalState {
searchPhrase: string;
content: any[];
hideExternalInfoDialog: boolean;
currentVod: any;
currentSerial: any;
sortType: string;
}
@Injectable({ providedIn: 'root' })
export class PortalStore extends ComponentStore<PortalState> {
constructor() {
super({
searchPhrase: '',
currentVod: undefined,
currentSerial: undefined,
content: [],
hideExternalInfoDialog:
localStorage.getItem('hideExternalInfoDialog') === 'true',
sortType: undefined
});
}
// selectors
readonly searchPhrase = this.selectSignal((state) => state.searchPhrase);
readonly currentSerial = this.selectSignal((state) => state.currentSerial);
readonly currentVod = this.selectSignal((state) => state.currentVod);
readonly hideExternalInfoDialog = this.selectSignal(
(state) => state.hideExternalInfoDialog
);
readonly sortType = this.selectSignal((state) => state.sortType);
readonly getContentById = (id: string) =>
this.selectSignal((state) => state.content.find((i) => i.id === id));
// reducers
readonly setSearchPhrase = this.updater(
(state, searchPhrase: string): PortalState => ({
...state,
searchPhrase,
})
);
readonly setContent = this.updater(
(state, content: any[]): PortalState => ({
...state,
content,
})
);
readonly setCurrentSerial = this.updater(
(state, currentSerial: any): PortalState => ({
...state,
currentSerial,
})
);
readonly setCurrentVod = this.updater(
(state, currentVod: any): PortalState => ({
...state,
currentVod,
})
);
readonly setHideExternalInfoDialog = this.updater(
(state, hideExternalInfoDialog: boolean): PortalState => {
localStorage.setItem(
'hideExternalInfoDialog',
hideExternalInfoDialog.toString()
);
return { ...state, hideExternalInfoDialog };
}
);
readonly setSortType = this.updater(
(state, sortType: string): PortalState => ({
...state,
sortType,
})
);
}
@@ -1,97 +0,0 @@
<section class="seasons-section">
<div class="section-header">
<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>
<div class="content-container">
@if (!selectedSeason) {
<!-- Seasons Grid -->
<div class="seasons-grid">
@for (
season of seasons | keyvalue: compareSeasons;
track $index
) {
<button
class="season-card"
(click)="selectSeason(season.key)"
[class.season-card--has-episodes]="season.value?.length"
>
<div class="season-card__number">{{ season.key }}</div>
<div class="season-card__label">Season</div>
@if (season.value?.length) {
<div class="season-card__episodes">
{{ season.value.length }}
{{
season.value.length === 1
? 'Episode'
: 'Episodes'
}}
</div>
}
<div class="season-card__arrow">
<mat-icon>chevron_right</mat-icon>
</div>
</button>
}
</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)"
>
<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>
}
<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>
}
</div>
</section>
@@ -1,2 +0,0 @@
// Import shared Netflix-style from xtream-electron
@use '../../xtream-electron/season-container/season-container.component.scss';
@@ -1,43 +0,0 @@
import { KeyValuePipe } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
imports: [KeyValuePipe, MatIcon],
})
export class SeasonContainerComponent {
@Input({ required: true }) seasons: Record<string, XtreamSerieEpisode[]>;
@Output() episodeClicked = new EventEmitter<any>();
selectedSeason: string;
compareSeasons(a: any, b: any): number {
return Number(a.key) - Number(b.key);
}
selectSeason(seasonKey: string) {
this.selectedSeason = seasonKey;
}
selectEpisode(episode: any) {
this.episodeClicked.emit(episode);
}
/**
* Safely get episode info - returns undefined if info is an empty array
* (Xtream API returns [] when no metadata available instead of null/object)
*/
getEpisodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
if (Array.isArray(episode.info) || !episode.info) {
return undefined;
}
return episode.info;
}
}
@@ -1,89 +0,0 @@
<div
class="container"
[style.background]="
item.info?.backdrop_path && item.info.backdrop_path.length > 0
? 'linear-gradient(to top, rgba(29,29,29,1) 0%, rgba(0,0,0,0.5) 100%), url(' +
item.info.backdrop_path[0] +
') repeat-x'
: 'none'
"
>
<div class="image">
@if (item.info.cover) {
<img [src]="item.info.cover" />
}
</div>
<div class="details">
<h2>{{ item.info.name }}</h2>
@if (item.info.plot) {
<label>
{{ item.info.plot }}
</label>
}
@if (item.info.releaseDate) {
<label>
<div class="label">{{ 'XTREAM.RELEASE_DATE' | translate }}:</div>
{{ item.info.releaseDate }}
</label>
}
@if (item.info.genre) {
<label>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item.info.genre }}
</label>
}
@if (item.info.rating) {
<label>
<div class="label">{{ 'XTREAM.RATING' | translate }}:</div>
{{ item.info.rating }}
</label>
}
@if (item.info.cast) {
<div>
<div class="label">{{ 'XTREAM.CAST' | translate }}:</div>
{{ item.info.cast }}
</div>
}
@if (item.info.director) {
<label>
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item.info.director }}
</label>
}
@if (item.info.episode_run_time) {
<label>
<div class="label">
{{ 'XTREAM.EPISODE_RUN_TIME' | translate }}:
</div>
{{ item.info.episode_run_time }}
</label>
}
<div class="action-buttons">
@if (!isFavorite) {
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
mat-flat-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
</div>
</div>
</div>
<app-season-container
[seasons]="item.episodes"
(episodeClicked)="playClicked.emit($event)"
/>
@@ -1,65 +0,0 @@
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { selectCurrentPlaylist } from 'm3u-state';
import { PlaylistsService } from 'services';
import { XtreamSerieDetails, XtreamSerieEpisode } from 'shared-interfaces';
import { SeasonContainerComponent } from '../season-container/season-container.component';
@Component({
selector: 'app-serial-details',
templateUrl: './serial-details.component.html',
styleUrls: ['../detail-view.scss'],
imports: [
MatButtonModule,
MatIconModule,
SeasonContainerComponent,
TranslateModule,
],
})
export class SerialDetailsComponent {
@Input({ required: true }) item: XtreamSerieDetails;
@Input() seriesId: number;
@Output() addToFavoritesClicked = new EventEmitter<any>();
@Output() playClicked = new EventEmitter<XtreamSerieEpisode>();
@Output() removeFromFavoritesClicked = new EventEmitter<number>();
store = inject(Store);
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
private playlistService = inject(PlaylistsService);
private route = inject(ActivatedRoute);
private portalId = this.route.snapshot.paramMap.get('id');
isFavorite = false;
ngOnInit(): void {
this.checkFavoriteStatus();
}
checkFavoriteStatus() {
this.playlistService
.getPortalFavorites(this.portalId)
.subscribe((favorites) => {
this.isFavorite = favorites.some(
(i) => (i as any).series_id === this.seriesId
);
});
}
toggleFavorite() {
if (this.isFavorite) {
this.removeFromFavoritesClicked.emit(this.seriesId);
} else {
this.addToFavoritesClicked.emit({
name: this.item.info.name,
series_id: this.seriesId,
cover: this.item.info.cover,
});
}
this.isFavorite = !this.isFavorite;
}
}
@@ -1,125 +0,0 @@
@let meta = normalizedMeta();
<!-- Hero Section with Backdrop -->
<app-content-hero
[title]="meta.title"
[description]="meta.description"
[posterUrl]="meta.posterUrl"
[backdropUrl]="meta.backdropUrl"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-container hero-tags>
@if (meta.year) {
<span class="details__tag">{{ meta.year }}</span>
}
@if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
}
@if (meta.duration) {
<span class="details__tag">{{ meta.duration }}</span>
}
@if (meta.ratingImdb) {
<span class="details__tag details__tag--rating">
⭐ {{ meta.ratingImdb }}
</span>
}
@if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (meta.actors) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ meta.actors }}</span>
</div>
}
@if (meta.director) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ meta.director }}</span>
</div>
}
@if (meta.ratingKinopoisk) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
<span class="value">{{ meta.ratingKinopoisk }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<!-- 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 }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
<!-- 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()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="onDownload()">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
</ng-container>
<!-- 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/' + 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"
allowfullscreen
></iframe>
</div>
</div>
}
</app-content-hero>
@@ -1,199 +0,0 @@
import {
Component,
computed,
inject,
input,
output,
OnDestroy,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
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',
styleUrls: ['../../xtream-electron/detail-view.scss'],
imports: [
ContentHeroComponent,
MatIcon,
MatProgressSpinnerModule,
SafePipe,
TranslatePipe,
],
})
export class VodDetailsComponent implements OnDestroy {
// ============ Inputs ============
/** 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 readonly downloadsService = inject(DownloadsService);
private readonly stalkerStore = inject(StalkerStore);
// ============ Computed State ============
/** 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 `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
/** 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');
});
/** 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');
});
// ============ Lifecycle ============
ngOnDestroy(): void {
// Clear selected item when leaving Stalker mode
if (this.isStalkerItem()) {
this.stalkerStore.clearSelectedItem();
}
}
// ============ Actions ============
/** Handle play button click */
onPlay(): void {
this.playClicked.emit(this.item());
}
/** Handle resume button click */
onResume(): void {
const pos = this.playbackPosition();
if (pos && pos > 0) {
this.resumeClicked.emit({
item: this.item(),
positionSeconds: pos,
});
}
}
/** 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,
item.playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -1,89 +0,0 @@
<app-navigation-bar
[breadcrumbs]="breadcrumbs"
[contentType]="selectedContentType"
[showCategories]="currentLayout !== 'error-view'"
(breadcrumbClicked)="breadcrumbClicked($event)"
(contentTypeChanged)="changeContentType($event)"
(searchTextChanged)="setSearchPhrase($event)"
(favoritesClicked)="favoritesClicked()"
[searchVisible]="
currentLayout === 'category' || currentLayout === 'category_content'
"
[sortVisible]="currentLayout === 'category_content'"
[favoriteVisible]="
currentLayout === 'category_content' ||
currentLayout === 'live-stream-favorites'
"
[contentTypeNavigationItems]="navigationContentTypes"
[activeLiveStream]="activeLiveStream"
[favoritesLiveStream$]="favoritesLiveStream$"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($event)"
/>
@if (isLoading) {
<mat-spinner />
} @else {
@switch (currentLayout) {
@case ('error-view') {
<app-playlist-error-view
[title]="this.errorViewInfo.title | translate"
[description]="this.errorViewInfo.message | translate"
/>
}
@case ('category') {
<app-category-view
(categoryClicked)="categoryClicked($event)"
[items]="items"
/>
}
@case ('category_content') {
@if (selectedContentType === 'itv') {
<app-live-stream-layout
[channels]="items"
[player]="player"
[streamUrl]="streamUrl"
[epgItems]="epgItems"
[activeLiveStream]="activeLiveStream"
(itemClicked)="itemClicked($event)"
/>
} @else {
<app-category-content-view
[items]="items"
(itemClicked)="itemClicked($event)"
/>
}
}
@case ('vod-details') {
<app-vod-details
[item]="getVodDetailsItem()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
/>
}
@case ('serie-details') {
<app-serial-details
[item]="$any(vodDetails)"
[seriesId]="contentId"
(playClicked)="playEpisode($event)"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($event)"
/>
}
@case ('favorites') {
<app-category-content-view
[items]="favorites$ | async"
(itemClicked)="itemClicked($event)"
/>
}
@case ('live-stream-favorites') {
<app-live-stream-layout
[channels]="favoritesLiveStream$ | async"
[player]="player"
[streamUrl]="streamUrl"
[epgItems]="epgItems"
(itemClicked)="itemClicked($event)"
/>
}
}
}
@@ -1,19 +0,0 @@
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
justify-content: flex-start;
}
.xtream-container {
height: calc(100vh - 140px);
overflow-y: auto;
display: flex;
justify-content: center;
}
mat-spinner {
margin-top: 10px;
align-self: center;
}
@@ -1,625 +0,0 @@
import { AsyncPipe } from '@angular/common';
import {
Component,
NgZone,
OnDestroy,
OnInit,
effect,
inject,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { selectCurrentPlaylist } from 'm3u-state';
import { Observable } from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
ERROR,
EpgItem,
IpcCommand,
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
Playlist,
REMOTE_CONTROL_CHANGE_CHANNEL,
VideoPlayer,
XTREAM_REQUEST,
XTREAM_RESPONSE,
XtreamCategory,
XtreamCodeActions,
XtreamLiveStream,
XtreamResponse,
XtreamSerieDetails,
XtreamSerieEpisode,
XtreamVodDetails,
VodDetailsItem,
createXtreamVodItem,
} from 'shared-interfaces';
import { LiveStreamLayoutComponent } from 'shared-portals';
import { SettingsStore } from '../services/settings-store.service';
import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../xtream-electron/player-dialog/player-dialog.component';
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
import { Breadcrumb, PortalActions } from './breadcrumb.interface';
import { CategoryContentViewComponent } from './category-content-view/category-content-view.component';
import { CategoryViewComponent } from './category-view/category-view.component';
import { ContentTypeNavigationItem } from './content-type-navigation-item.interface';
import { ContentType } from './content-type.enum';
import { NavigationBarComponent } from './navigation-bar/navigation-bar.component';
import { PortalStore } from './portal.store';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { VodDetailsComponent } from './vod-details/vod-details.component';
function b64DecodeUnicode(str: string) {
return decodeURIComponent(
Array.prototype.map
.call(atob(str), function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
})
.join('')
);
}
const ContentTypes = {
[ContentType.ITV]: {
getContentAction: XtreamCodeActions.GetLiveStreams,
getCategoryAction: XtreamCodeActions.GetLiveCategories,
},
[ContentType.VODS]: {
getContentAction: XtreamCodeActions.GetVodStreams,
getCategoryAction: XtreamCodeActions.GetVodCategories,
getDetailsAction: XtreamCodeActions.GetVodInfo,
},
[ContentType.SERIES]: {
getContentAction: XtreamCodeActions.GetSeries,
getCategoryAction: XtreamCodeActions.GetSeriesCategories,
getDetailsAction: XtreamCodeActions.GetSeriesInfo,
},
};
type LayoutView =
| 'category'
| 'category_content'
| 'vod-details'
| 'player'
| 'serie-details'
| 'favorites'
| 'error-view'
| 'live-stream-favorites';
@Component({
selector: 'app-xtream-main-container',
templateUrl: './xtream-main-container.component.html',
styleUrls: ['./xtream-main-container.component.scss'],
imports: [
MatButtonToggleModule,
CategoryViewComponent,
MatButtonModule,
MatCardModule,
MatIconModule,
NavigationBarComponent,
VodDetailsComponent,
CategoryContentViewComponent,
SerialDetailsComponent,
MatProgressSpinnerModule,
AsyncPipe,
PlaylistErrorViewComponent,
TranslateModule,
LiveStreamLayoutComponent,
],
})
export class XtreamMainContainerComponent implements OnInit, OnDestroy {
private readonly dataService = inject(DataService);
private readonly dialog = inject(MatDialog);
private readonly ngZone = inject(NgZone);
private readonly playlistService = inject(PlaylistsService);
private readonly portalStore = inject(PortalStore);
private readonly settingsStore = inject(SettingsStore);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
navigationContentTypes: ContentTypeNavigationItem[] = [
{
contentType: ContentType.ITV,
label: 'Live Streams',
icon: 'live_tv',
},
{
contentType: ContentType.VODS,
label: 'VOD Streams',
icon: 'movie',
},
{
contentType: ContentType.SERIES,
label: 'Series',
icon: 'video_library',
},
];
player: VideoPlayer;
favorites$: Observable<any>;
favoritesLiveStream$: Observable<any>;
breadcrumbs: Breadcrumb[] = [];
items = [];
listeners = [];
selectedContentType = ContentType.VODS;
currentLayout: LayoutView = 'category';
vodDetails!: XtreamVodDetails | XtreamSerieDetails;
settings = this.settingsStore;
isLoading = true;
searchPhrase = this.portalStore.searchPhrase();
contentId: number;
errorViewInfo = { title: '', message: '' };
streamUrl: string;
epgItems = [];
hideExternalInfoDialog = this.portalStore.hideExternalInfoDialog;
activeLiveStream: XtreamLiveStream;
commandsList = [
new IpcCommand(XTREAM_RESPONSE, (response: XtreamResponse) =>
this.handleResponse(response)
),
new IpcCommand(ERROR, (response: { message: string; status: number }) =>
this.showErrorAsNotification(response)
),
new IpcCommand(
REMOTE_CONTROL_CHANGE_CHANNEL,
(response: { type: 'up' | 'down' }) => {
this.remoteControlChangeChannel(response.type);
}
),
];
constructor() {
effect(() => {
const playlist = this.currentPlaylist() as Playlist;
if (!playlist || !playlist._id) return;
if (playlist) {
this.getCategories(this.selectedContentType);
this.favorites$ = this.playlistService.getPortalFavorites(
playlist._id
);
this.favoritesLiveStream$ =
this.playlistService.getPortalLiveStreamFavorites(
playlist._id
);
}
});
}
ngOnInit() {
this.setInitialBreadcrumb();
this.commandsList.forEach((command) => {
if (this.dataService.isElectron) {
this.dataService.listenOn(command.id, (_event, response) =>
this.ngZone.run(() => command.callback(response))
);
} else {
const cb = (response) => {
if (response.data.type === command.id) {
command.callback(response.data);
}
};
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
}
});
}
handleResponse(response: XtreamResponse) {
if (!response.payload) {
this.errorViewInfo = {
title: 'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE',
message: 'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION',
};
this.currentLayout = 'error-view';
} else {
if ((response.payload as any)?.user_info?.status === 'Expired') {
this.errorViewInfo = {
title: 'PORTALS.ERROR_VIEW.ACCOUNT_EXPIRED.TITLE',
message: 'PORTALS.ERROR_VIEW.ACCOUNT_EXPIRED.DESCRIPTION',
};
this.currentLayout = 'error-view';
} else if ((response.payload as any)?.user_info?.auth === 0) {
this.errorViewInfo = {
title: 'PORTALS.ERROR_VIEW.UNAUTHORIZED.TITLE',
message: 'PORTALS.ERROR_VIEW.UNAUTHORIZED.DESCRIPTION',
};
this.currentLayout = 'error-view';
}
}
switch (response.action) {
case XtreamCodeActions.GetSeriesCategories:
case XtreamCodeActions.GetVodCategories:
case XtreamCodeActions.GetLiveCategories:
case XtreamCodeActions.GetVodStreams:
case XtreamCodeActions.GetLiveStreams:
case XtreamCodeActions.GetSeries:
this.items = response.payload as unknown[];
break;
case XtreamCodeActions.GetVodInfo:
this.currentLayout = 'vod-details';
this.vodDetails = response.payload as XtreamVodDetails;
break;
case XtreamCodeActions.GetSeriesInfo:
this.currentLayout = 'serie-details';
this.vodDetails = response.payload as XtreamSerieDetails;
break;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
case 'get_short_epg':
this.epgItems = (
(response.payload as any).epg_listings as EpgItem[]
).map((i) => ({
...i,
title: b64DecodeUnicode(i.title).trim(),
description: b64DecodeUnicode(i.description).trim(),
}));
break;
default:
break;
}
this.isLoading = false;
}
getCategories(contentType: ContentType = this.selectedContentType) {
this.currentLayout = 'category';
const action = ContentTypes[contentType].getCategoryAction;
this.setInitialBreadcrumb(action);
this.sendRequest({ action });
}
ngOnDestroy(): void {
this.portalStore.setSearchPhrase('');
if (this.dataService.isElectron) {
this.commandsList.forEach((command) =>
this.dataService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
window.removeEventListener('message', listener);
});
}
}
setInitialBreadcrumb(action = XtreamCodeActions.GetVodCategories) {
this.breadcrumbs = [{ title: 'All categories', action }];
}
categoryClicked(item: XtreamCategory) {
this.items = [];
this.streamUrl = undefined;
this.portalStore.setSearchPhrase('');
const action = ContentTypes[this.selectedContentType].getContentAction;
this.breadcrumbs.push({
title: item.category_name,
category_id: item.category_id,
action,
});
this.sendRequest({ action, category_id: item.category_id });
this.currentLayout = 'category_content';
}
itemClicked(item: any) {
let action;
if (item.stream_type && item.stream_type === 'movie') {
this.items = [];
action = XtreamCodeActions.GetVodInfo;
this.breadcrumbs.push({ title: item.name, action });
this.contentId = item.stream_id;
this.sendRequest({ action, vod_id: item.stream_id });
} else if (item.stream_type && item.stream_type === 'live') {
this.sendRequest({
action: 'get_short_epg',
stream_id: item.stream_id,
limit: 10,
});
this.activeLiveStream = item;
this.playLiveStream(item);
} else if (item.series_id) {
this.items = [];
action = XtreamCodeActions.GetSeriesInfo;
this.breadcrumbs.push({ title: item.name, action });
this.contentId = item.series_id;
this.sendRequest({ action, series_id: item.series_id });
}
}
playLiveStream(item: XtreamLiveStream) {
const { serverUrl, username, password } =
this.currentPlaylist() as Playlist;
const streamUrl = `${serverUrl}/${item.stream_type}/${username}/${password}/${item.stream_id}.m3u8`;
this.activeLiveStream = item;
this.openPlayer(streamUrl, item.name);
}
openPlayer(streamUrl: string, title: string) {
this.streamUrl = streamUrl;
this.player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
const playlist = this.currentPlaylist() as Playlist;
if (this.player === VideoPlayer.MPV) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: this.settingsStore.mpvPlayerPath(),
title,
'user-agent': playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
});
} else if (this.player === VideoPlayer.VLC) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: this.settingsStore.vlcPlayerPath(),
title,
'user-agent': playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
});
} else {
if (this.selectedContentType !== ContentType.ITV) {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
}
);
}
}
}
playVod(vodItem: XtreamVodDetails) {
const { serverUrl, username, password } =
this.currentPlaylist() as Playlist;
this.items = [];
const streamUrl = `${serverUrl}/movie/${username}/${password}/${vodItem.movie_data.stream_id}.${vodItem.movie_data.container_extension}`;
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 } =
playlist;
const player = this.settingsStore.player();
const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`;
if (player === VideoPlayer.MPV) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
title: episode.title,
'user-agent': userAgent,
referer: referrer,
origin: origin,
});
} else if (player === VideoPlayer.VLC) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
title: episode.title,
'user-agent': userAgent,
referer: referrer,
origin: origin,
});
} else {
this.dialog.open(PlayerDialogComponent, {
data: { streamUrl, player, title: episode.title },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
});
}
}
changeContentType(contentType: ContentType) {
this.selectedContentType = contentType;
this.getCategories();
}
showErrorAsNotification(response: { message: string; status: number }) {
if ('status' in response && response.status === 401) {
this.errorViewInfo = {
title: 'PORTALS.ERROR_VIEW.UNAUTHORIZED.TITLE',
message: 'PORTALS.ERROR_VIEW.UNAUTHORIZED.DESCRIPTION',
};
this.currentLayout = 'error-view';
} else if ('status' in response && response.status === 404) {
this.errorViewInfo = {
title: 'PORTALS.ERROR_VIEW.NOT_FOUND.TITLE',
message: 'PORTALS.ERROR_VIEW.NOT_FOUND.DESCRIPTION',
};
this.currentLayout = 'error-view';
}
this.snackBar.open(
`Error: ${response?.message ?? 'Something went wrong'} (Status: ${
response?.status ?? 0
})`,
null,
{ duration: 4000 }
);
this.isLoading = false;
}
/**
* Should get the position of the clicked breadcrumb and remove all the items after
* @param breadcrumb clicked breadcrumb item
*/
breadcrumbClicked(breadcrumb: Breadcrumb) {
const itemIndex = this.breadcrumbs.findIndex((i) => i === breadcrumb);
// do nothing if last breadcrumb child was clicked
if (itemIndex === this.breadcrumbs.length - 1) return;
this.items = [];
this.breadcrumbs.splice(
itemIndex + 1,
this.breadcrumbs.length - itemIndex - 1
);
this.currentLayout = this.getLayoutViewBasedOnAction(breadcrumb.action);
this.sendRequest({
action: breadcrumb.action,
...(breadcrumb.category_id
? { category_id: breadcrumb.category_id }
: {}),
});
}
getLayoutViewBasedOnAction(action: PortalActions) {
let result: LayoutView = 'category';
switch (action) {
case XtreamCodeActions.GetLiveCategories:
case XtreamCodeActions.GetVodCategories:
case XtreamCodeActions.GetSeriesCategories:
result = 'category';
break;
case XtreamCodeActions.GetLiveStreams:
case XtreamCodeActions.GetVodStreams:
case XtreamCodeActions.GetSeries:
result = 'category_content';
break;
case XtreamCodeActions.GetVodInfo:
case XtreamCodeActions.GetSeriesInfo:
result = 'vod-details';
break;
default:
console.error(`Error: Unknown action was provided: ${action}`);
break;
}
return result;
}
sendRequest(params: Record<string, string | number>) {
if (params.action !== 'get_short_epg') {
this.isLoading = true;
}
const playlist = this.currentPlaylist() as Playlist;
if (!playlist || !playlist._id) return;
const { serverUrl, username, password } = playlist;
this.dataService.sendIpcEvent(XTREAM_REQUEST, {
url: serverUrl,
params: {
password,
username,
...params,
},
});
}
setSearchPhrase(searchPhrase: string) {
this.searchPhrase = searchPhrase;
}
addToFavorites(item: any) {
const playlist = this.currentPlaylist() as Playlist;
if (!playlist || !playlist._id) return;
this.playlistService
.addPortalFavorite(playlist._id, item)
.subscribe(() => {
this.snackBar.open(
this.translate.instant('PORTALS.ADDED_TO_FAVORITES'),
null,
{
duration: 1000,
}
);
});
}
removeFromFavorites(favoriteId: number) {
const playlist = this.currentPlaylist() as Playlist;
if (!playlist || !playlist._id) return;
this.playlistService
.removeFromPortalFavorites(playlist._id, favoriteId)
.subscribe(() => {
this.snackBar.open(
this.translate.instant('PORTALS.REMOVED_FROM_FAVORITES'),
null,
{
duration: 1000,
}
);
});
}
favoritesClicked() {
if (this.selectedContentType === ContentType.ITV) {
this.currentLayout = 'live-stream-favorites';
} else {
this.currentLayout = 'favorites';
}
this.setInitialBreadcrumb();
}
remoteControlChangeChannel(type: 'up' | 'down') {
if (
this.currentLayout === 'category_content' &&
this.activeLiveStream
) {
let nextItem;
const index = this.activeLiveStream.num - 1;
if (type === 'up') {
if (index - 1 >= 0) {
nextItem = this.items[index - 1];
}
} else if (type === 'down') {
const index = this.activeLiveStream.num - 1;
if (index + 1 < this.items.length) {
nextItem = this.items[index + 1];
}
}
this.itemClicked(nextItem);
}
}
}