mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
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', */
|
||||
}
|
||||
@@ -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(['/']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user