From a9be475ce15c1d45766b16606787eba35d460bbe Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 24 Feb 2026 19:12:23 +0100 Subject: [PATCH] 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. --- .../src/app/xtream/breadcrumb.interface.ts | 9 - .../category-content-view.component.html | 61 -- .../category-content-view.component.scss | 84 --- .../category-content-view.component.ts | 32 - .../category-view.component.scss | 23 - .../category-view/category-view.component.ts | 71 -- .../content-type-navigation-item.interface.ts | 7 - apps/web/src/app/xtream/content-type.enum.ts | 8 - apps/web/src/app/xtream/detail-view.scss | 153 ----- .../navigation-bar.component.html | 119 ---- .../navigation-bar.component.scss | 108 --- .../navigation-bar.component.ts | 172 ----- .../playlist-error-view.component.html | 35 - .../playlist-error-view.component.scss | 43 -- .../playlist-error-view.component.ts | 58 -- apps/web/src/app/xtream/portal.store.ts | 86 --- .../season-container.component.html | 97 --- .../season-container.component.scss | 2 - .../season-container.component.ts | 43 -- .../serial-details.component.html | 89 --- .../serial-details.component.ts | 65 -- .../vod-details/vod-details.component.html | 125 ---- .../vod-details/vod-details.component.ts | 199 ------ .../xtream-main-container.component.html | 89 --- .../xtream-main-container.component.scss | 19 - .../xtream/xtream-main-container.component.ts | 625 ------------------ 26 files changed, 2422 deletions(-) delete mode 100644 apps/web/src/app/xtream/breadcrumb.interface.ts delete mode 100644 apps/web/src/app/xtream/category-content-view/category-content-view.component.html delete mode 100644 apps/web/src/app/xtream/category-content-view/category-content-view.component.scss delete mode 100644 apps/web/src/app/xtream/category-content-view/category-content-view.component.ts delete mode 100644 apps/web/src/app/xtream/category-view/category-view.component.scss delete mode 100644 apps/web/src/app/xtream/category-view/category-view.component.ts delete mode 100644 apps/web/src/app/xtream/content-type-navigation-item.interface.ts delete mode 100644 apps/web/src/app/xtream/content-type.enum.ts delete mode 100644 apps/web/src/app/xtream/detail-view.scss delete mode 100644 apps/web/src/app/xtream/navigation-bar/navigation-bar.component.html delete mode 100644 apps/web/src/app/xtream/navigation-bar/navigation-bar.component.scss delete mode 100644 apps/web/src/app/xtream/navigation-bar/navigation-bar.component.ts delete mode 100644 apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.html delete mode 100644 apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.scss delete mode 100644 apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts delete mode 100644 apps/web/src/app/xtream/portal.store.ts delete mode 100644 apps/web/src/app/xtream/season-container/season-container.component.html delete mode 100644 apps/web/src/app/xtream/season-container/season-container.component.scss delete mode 100644 apps/web/src/app/xtream/season-container/season-container.component.ts delete mode 100644 apps/web/src/app/xtream/serial-details/serial-details.component.html delete mode 100644 apps/web/src/app/xtream/serial-details/serial-details.component.ts delete mode 100644 apps/web/src/app/xtream/vod-details/vod-details.component.html delete mode 100644 apps/web/src/app/xtream/vod-details/vod-details.component.ts delete mode 100644 apps/web/src/app/xtream/xtream-main-container.component.html delete mode 100644 apps/web/src/app/xtream/xtream-main-container.component.scss delete mode 100644 apps/web/src/app/xtream/xtream-main-container.component.ts diff --git a/apps/web/src/app/xtream/breadcrumb.interface.ts b/apps/web/src/app/xtream/breadcrumb.interface.ts deleted file mode 100644 index e70fcd1c7..000000000 --- a/apps/web/src/app/xtream/breadcrumb.interface.ts +++ /dev/null @@ -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; -} diff --git a/apps/web/src/app/xtream/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream/category-content-view/category-content-view.component.html deleted file mode 100644 index d045408ef..000000000 --- a/apps/web/src/app/xtream/category-content-view/category-content-view.component.html +++ /dev/null @@ -1,61 +0,0 @@ -@if (items?.length > 0) { -
- @for ( - item of items - | filterBy: searchPhrase() : 'name' - | sortBy: sortType(); - track $index - ) { - @let imgSrc = item.stream_icon || item.cover || item.logo; - - - @if (imgSrc) { - channel logo - } @else { -
- } - @if (item.rating || item.rating_imdb) { -
- star{{ item.rating || item.rating_imdb }} -
- } -
- -
{{ item.name || 'No name' }}
-
-
- } - @if (!(items | filterBy: searchPhrase() : 'name')?.length) { - - } -
-} @else { - -} diff --git a/apps/web/src/app/xtream/category-content-view/category-content-view.component.scss b/apps/web/src/app/xtream/category-content-view/category-content-view.component.scss deleted file mode 100644 index 2851e4f6e..000000000 --- a/apps/web/src/app/xtream/category-content-view/category-content-view.component.scss +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/app/xtream/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream/category-content-view/category-content-view.component.ts deleted file mode 100644 index 2f51ab392..000000000 --- a/apps/web/src/app/xtream/category-content-view/category-content-view.component.ts +++ /dev/null @@ -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(); - - portalStore = inject(PortalStore); - searchPhrase = this.portalStore.searchPhrase; - sortType = this.portalStore.sortType; -} diff --git a/apps/web/src/app/xtream/category-view/category-view.component.scss b/apps/web/src/app/xtream/category-view/category-view.component.scss deleted file mode 100644 index 1c017397c..000000000 --- a/apps/web/src/app/xtream/category-view/category-view.component.scss +++ /dev/null @@ -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%; - } -} diff --git a/apps/web/src/app/xtream/category-view/category-view.component.ts b/apps/web/src/app/xtream/category-view/category-view.component.ts deleted file mode 100644 index 579e5c993..000000000 --- a/apps/web/src/app/xtream/category-view/category-view.component.ts +++ /dev/null @@ -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 - ) { - - - {{ - item.category_name || - item.name || - 'No category name' - }} - - - } - @if ( - !(items() | filterBy: searchPhrase() : 'category_name')?.length - ) { - - } - } @else { - - } - `, - styleUrl: './category-view.component.scss', - imports: [ - FilterPipe, - MatCardModule, - MatIconModule, - PlaylistErrorViewComponent, - TranslatePipe, - ], -}) -export class CategoryViewComponent { - readonly items = input.required(); - - readonly categoryClicked = output(); - - private readonly portalStore = inject(PortalStore); - readonly searchPhrase = this.portalStore.searchPhrase; -} diff --git a/apps/web/src/app/xtream/content-type-navigation-item.interface.ts b/apps/web/src/app/xtream/content-type-navigation-item.interface.ts deleted file mode 100644 index c81d6d61b..000000000 --- a/apps/web/src/app/xtream/content-type-navigation-item.interface.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ContentType } from './content-type.enum'; - -export interface ContentTypeNavigationItem { - contentType: ContentType; - label: string; - icon: string; -} diff --git a/apps/web/src/app/xtream/content-type.enum.ts b/apps/web/src/app/xtream/content-type.enum.ts deleted file mode 100644 index 99b3713bf..000000000 --- a/apps/web/src/app/xtream/content-type.enum.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum ContentType { - STB = 'stb', - VODS = 'vod', - SERIES = 'series', - ITV = 'itv', - FAVORITES = 'favorites', - /* RADIO = 'radio', */ -} diff --git a/apps/web/src/app/xtream/detail-view.scss b/apps/web/src/app/xtream/detail-view.scss deleted file mode 100644 index e682cc419..000000000 --- a/apps/web/src/app/xtream/detail-view.scss +++ /dev/null @@ -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; -} diff --git a/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.html b/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.html deleted file mode 100644 index 32fc059f5..000000000 --- a/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.html +++ /dev/null @@ -1,119 +0,0 @@ - -
- -
-
- @if (favoriteVisible && activeLiveStream) { - - } -
- @if (sortVisible) { -
- -
- } - @if (searchVisible) { - - } -
- -
- - - -
-
-
diff --git a/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.scss b/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.scss deleted file mode 100644 index ca9f681ec..000000000 --- a/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.scss +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.ts b/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.ts deleted file mode 100644 index 384b452a8..000000000 --- a/apps/web/src/app/xtream/navigation-bar/navigation-bar.component.ts +++ /dev/null @@ -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; - @Input() favoriteVisible = false; - - @Output() contentTypeChanged = new EventEmitter(); - @Output() breadcrumbClicked = new EventEmitter(); - @Output() favoritesClicked = new EventEmitter(); - @Output() searchTextChanged = new EventEmitter(); - @Output() addToFavoritesClicked = new EventEmitter(); - @Output() removeFromFavoritesClicked = new EventEmitter(); - - ContentTypeEnum = ContentType; - dialog = inject(MatDialog); - portalStore = inject(PortalStore); - store = inject(Store); - searchPhrase = this.portalStore.searchPhrase; - searchPhraseUpdate = new Subject(); - 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 }, - }); - } -} diff --git a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.html b/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.html deleted file mode 100644 index cd5121216..000000000 --- a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.html +++ /dev/null @@ -1,35 +0,0 @@ - diff --git a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.scss b/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.scss deleted file mode 100644 index bb944332c..000000000 --- a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.scss +++ /dev/null @@ -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; - } - } - } -} diff --git a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts b/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts deleted file mode 100644 index 3eafdc34c..000000000 --- a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts +++ /dev/null @@ -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(['/']); - } -} diff --git a/apps/web/src/app/xtream/portal.store.ts b/apps/web/src/app/xtream/portal.store.ts deleted file mode 100644 index 1bff9098d..000000000 --- a/apps/web/src/app/xtream/portal.store.ts +++ /dev/null @@ -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 { - 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, - }) - ); -} diff --git a/apps/web/src/app/xtream/season-container/season-container.component.html b/apps/web/src/app/xtream/season-container/season-container.component.html deleted file mode 100644 index 0d21fc639..000000000 --- a/apps/web/src/app/xtream/season-container/season-container.component.html +++ /dev/null @@ -1,97 +0,0 @@ -
-
-

Seasons and Episodes

- @if (selectedSeason) { - - } -
- -
- @if (!selectedSeason) { - -
- @for ( - season of seasons | keyvalue: compareSeasons; - track $index - ) { - - } -
- } @else { - -
- @for (episode of seasons[selectedSeason]; track $index) { - @let episodeInfo = getEpisodeInfo(episode); -
-
- @if (episodeInfo?.movie_image) { - Episode thumbnail - } @else { -
- play_circle_outline -
- } -
-
- play_arrow -
-
-
- {{ episode.episode_num }} -
-
-
-

- {{ episode.episode_num }}. {{ episode.title }} -

- @if (episodeInfo?.plot) { -

- {{ episodeInfo.plot }} -

- } - @if (episodeInfo?.duration) { - - {{ episodeInfo.duration }} - - } -
-
- } -
- } -
-
diff --git a/apps/web/src/app/xtream/season-container/season-container.component.scss b/apps/web/src/app/xtream/season-container/season-container.component.scss deleted file mode 100644 index 66c34312f..000000000 --- a/apps/web/src/app/xtream/season-container/season-container.component.scss +++ /dev/null @@ -1,2 +0,0 @@ -// Import shared Netflix-style from xtream-electron -@use '../../xtream-electron/season-container/season-container.component.scss'; diff --git a/apps/web/src/app/xtream/season-container/season-container.component.ts b/apps/web/src/app/xtream/season-container/season-container.component.ts deleted file mode 100644 index e7bf01ea2..000000000 --- a/apps/web/src/app/xtream/season-container/season-container.component.ts +++ /dev/null @@ -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; - - @Output() episodeClicked = new EventEmitter(); - - 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; - } -} diff --git a/apps/web/src/app/xtream/serial-details/serial-details.component.html b/apps/web/src/app/xtream/serial-details/serial-details.component.html deleted file mode 100644 index 5aa532d1b..000000000 --- a/apps/web/src/app/xtream/serial-details/serial-details.component.html +++ /dev/null @@ -1,89 +0,0 @@ -
-
- @if (item.info.cover) { - - } -
-
-

{{ item.info.name }}

- @if (item.info.plot) { - - } - @if (item.info.releaseDate) { - - } - @if (item.info.genre) { - - } - @if (item.info.rating) { - - } - @if (item.info.cast) { -
-
{{ 'XTREAM.CAST' | translate }}:
- {{ item.info.cast }} -
- } - @if (item.info.director) { - - } - @if (item.info.episode_run_time) { - - } -
- @if (!isFavorite) { - - } @else { - - } - -
-
-
- - diff --git a/apps/web/src/app/xtream/serial-details/serial-details.component.ts b/apps/web/src/app/xtream/serial-details/serial-details.component.ts deleted file mode 100644 index ad9ed43be..000000000 --- a/apps/web/src/app/xtream/serial-details/serial-details.component.ts +++ /dev/null @@ -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(); - @Output() playClicked = new EventEmitter(); - @Output() removeFromFavoritesClicked = new EventEmitter(); - - 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; - } -} diff --git a/apps/web/src/app/xtream/vod-details/vod-details.component.html b/apps/web/src/app/xtream/vod-details/vod-details.component.html deleted file mode 100644 index b77ad7283..000000000 --- a/apps/web/src/app/xtream/vod-details/vod-details.component.html +++ /dev/null @@ -1,125 +0,0 @@ -@let meta = normalizedMeta(); - - - - - - @if (meta.year) { - {{ meta.year }} - } - @if (meta.genre) { - {{ meta.genre }} - } - @if (meta.duration) { - {{ meta.duration }} - } - @if (meta.ratingImdb) { - - ⭐ {{ meta.ratingImdb }} - - } - @if (meta.country) { - {{ meta.country }} - } - - - - - @if (meta.actors) { -
- {{ 'XTREAM.ACTORS' | translate }} - {{ meta.actors }} -
- } - @if (meta.director) { -
- {{ 'XTREAM.DIRECTOR' | translate }} - {{ meta.director }} -
- } - @if (meta.ratingKinopoisk) { -
- {{ 'XTREAM.KINOPOISK_RATING' | translate }} - {{ meta.ratingKinopoisk }} -
- } -
- - - - - @if (hasPlaybackPosition()) { - - - } @else { - - } - - - @if (!isFavorite()) { - - } @else { - - } - - - @if (isElectron()) { - @if (isDownloaded()) { - - } @else if (isDownloading()) { - - } @else { - - } - } - - - - @if (meta.youtubeTrailer) { -
-

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

-
- -
-
- } -
diff --git a/apps/web/src/app/xtream/vod-details/vod-details.component.ts b/apps/web/src/app/xtream/vod-details/vod-details.component.ts deleted file mode 100644 index c3c37845d..000000000 --- a/apps/web/src/app/xtream/vod-details/vod-details.component.ts +++ /dev/null @@ -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 - * - * ``` - */ -@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(); - - /** Whether this item is in favorites (managed by parent) */ - readonly isFavorite = input(false); - - /** Playback position in seconds for resume feature (managed by parent) */ - readonly playbackPosition = input(null); - - // ============ Outputs ============ - - /** Emitted when play button is clicked */ - readonly playClicked = output(); - - /** 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(); - - /** Emitted when download is requested (parent handles URL construction) */ - readonly downloadRequested = output(); - - // ============ 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 { - const item = this.item(); - const vodId = getVodNumericId(item); - - const filePath = this.downloadsService.getDownloadedFilePath( - vodId, - item.playlistId, - 'vod' - ); - - if (filePath) { - await this.downloadsService.playDownload(filePath); - } - } -} diff --git a/apps/web/src/app/xtream/xtream-main-container.component.html b/apps/web/src/app/xtream/xtream-main-container.component.html deleted file mode 100644 index c1fa6d8ce..000000000 --- a/apps/web/src/app/xtream/xtream-main-container.component.html +++ /dev/null @@ -1,89 +0,0 @@ - - -@if (isLoading) { - -} @else { - @switch (currentLayout) { - @case ('error-view') { - - } - @case ('category') { - - } - @case ('category_content') { - @if (selectedContentType === 'itv') { - - } @else { - - } - } - @case ('vod-details') { - - } - @case ('serie-details') { - - } - @case ('favorites') { - - } - @case ('live-stream-favorites') { - - } - } -} diff --git a/apps/web/src/app/xtream/xtream-main-container.component.scss b/apps/web/src/app/xtream/xtream-main-container.component.scss deleted file mode 100644 index ee7cfa7e5..000000000 --- a/apps/web/src/app/xtream/xtream-main-container.component.scss +++ /dev/null @@ -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; -} diff --git a/apps/web/src/app/xtream/xtream-main-container.component.ts b/apps/web/src/app/xtream/xtream-main-container.component.ts deleted file mode 100644 index 702a9ae4a..000000000 --- a/apps/web/src/app/xtream/xtream-main-container.component.ts +++ /dev/null @@ -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; - favoritesLiveStream$: Observable; - 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, - { - 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) { - 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); - } - } -}