feat(tauri): new xtream module

This commit is contained in:
4gray committed 2024-12-16 01:36:56 +01:00
1 parent 1d59396c50
commit 9ffef67813
46 files changed
+3353 -82

No files matched your search

@@ -0,0 +1,86 @@
.account-info-card {
margin: 16px;
max-width: 1200px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.info-section {
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.info-section h3 {
margin-bottom: 16px;
padding-left: 16px;
font-size: 1.1em;
border-bottom: 1px solid #333;
padding-bottom: 8px;
}
mat-list-item {
height: auto !important;
margin-bottom: 8px;
padding: 8px 16px;
}
strong {
margin-right: 8px;
min-width: 140px;
display: inline-block;
}
.active {
color: #4caf50;
font-weight: 500;
}
.trial {
color: #ff9800;
font-weight: 500;
}
.formats {
color: #2196f3;
}
.ports {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.ports span {
padding: 4px 8px;
background: #2196f3;
border-radius: 4px;
font-size: 0.9em;
}
.welcome-message {
border: 1px solid #333;
padding: 16px;
border-radius: 4px;
margin-bottom: 16px;
text-align: center;
font-style: italic;
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
}
.account-info-card {
margin: 8px;
}
.ports {
flex-direction: column;
gap: 8px;
}
}
@@ -0,0 +1,206 @@
import { CommonModule } from '@angular/common';
import { Component, Inject, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatListModule } from '@angular/material/list';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '../../services/data.service';
import { selectActivePlaylist } from '../../state/selectors';
import { XtreamAccountInfo } from './account-info.interface';
@Component({
selector: 'app-account-info',
standalone: true,
imports: [
CommonModule,
MatButton,
MatDialogModule,
MatCardModule,
MatListModule,
TranslateModule,
],
template: `
<h2 mat-dialog-title>Account Information</h2>
<mat-dialog-content class="mat-typography">
<div
*ngIf="accountInfo?.user_info?.message"
class="welcome-message"
>
{{ accountInfo?.user_info?.message }}
</div>
<div class="info-grid">
<div class="info-section">
<h3>User Information</h3>
<mat-list>
<mat-list-item>
Status:
<span [class.active]="isActive">{{
accountInfo?.user_info?.status
}}</span>
</mat-list-item>
<mat-list-item>
Username:
{{ accountInfo?.user_info?.username }}
</mat-list-item>
<mat-list-item>
Active Connections:
{{ accountInfo?.user_info?.active_cons }}/{{
accountInfo?.user_info?.max_connections
}}
</mat-list-item>
<mat-list-item>
Created:
{{ formattedCreatedDate }}
</mat-list-item>
<mat-list-item>
Expires: {{ formattedExpDate }}
</mat-list-item>
<mat-list-item>
Trial Account:
<span [class.trial]="isTrial">{{
isTrial ? 'Yes' : 'No'
}}</span>
</mat-list-item>
<mat-list-item>
Allowed Formats:
<span class="formats">
{{
accountInfo?.user_info?.allowed_output_formats?.join(
', '
)
}}
</span>
</mat-list-item>
</mat-list>
</div>
<div class="info-section">
<h3>Server Information</h3>
<mat-list>
<mat-list-item>
Live TV:
{{ liveStreamsCount }}
</mat-list-item>
<mat-list-item>
Movies:
{{ vodStreamsCount }}
</mat-list-item>
<mat-list-item>
TV Series:
{{ seriesCount }}
</mat-list-item>
<mat-list-item>
URL:
{{ accountInfo?.server_info?.url }}
</mat-list-item>
<mat-list-item>
Protocol:
{{ accountInfo?.server_info?.server_protocol }}
</mat-list-item>
<mat-list-item>
Timezone:
{{ accountInfo?.server_info?.timezone }}
</mat-list-item>
<mat-list-item>
Server Time:
{{ accountInfo?.server_info?.time_now }}
</mat-list-item>
<mat-list-item>
Ports:
<div class="ports">
<span
>HTTP:
{{ accountInfo?.server_info?.port }}</span
>
<span
>HTTPS:
{{
accountInfo?.server_info?.https_port
}}</span
>
<span
>RTMP:
{{
accountInfo?.server_info?.rtmp_port
}}</span
>
</div>
</mat-list-item>
</mat-list>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
`,
styleUrl: './account-info.component.scss',
})
export class AccountInfoComponent {
accountInfo: XtreamAccountInfo;
formattedExpDate: string;
formattedCreatedDate: string;
private readonly dataService = inject(DataService);
private readonly store = inject(Store);
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
readonly currentPlaylist = this.store.selectSignal(selectActivePlaylist);
constructor(
@Inject(MAT_DIALOG_DATA)
data: {
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
}
) {
this.setAccountInfo();
this.vodStreamsCount = data.vodStreamsCount;
this.liveStreamsCount = data.liveStreamsCount;
this.seriesCount = data.seriesCount;
}
async setAccountInfo() {
const playlist = this.currentPlaylist();
console.log(playlist);
if (!playlist) return;
try {
this.accountInfo = await this.dataService.fetchData(
`${playlist.serverUrl}/player_api.php`,
{
username: playlist.username,
password: playlist.password,
action: 'get_account_info',
}
);
console.log(this.accountInfo);
if (this.accountInfo) {
this.formattedExpDate = new Date(
parseInt(this.accountInfo.user_info.exp_date) * 1000
).toLocaleDateString();
this.formattedCreatedDate = new Date(
parseInt(this.accountInfo.user_info.created_at) * 1000
).toLocaleDateString();
}
} catch (error) {
console.error('Failed to fetch account info:', error);
}
}
get isActive(): boolean {
return this.accountInfo?.user_info?.status === 'Active';
}
get isTrial(): boolean {
return this.accountInfo?.user_info?.is_trial === '1';
}
}
@@ -0,0 +1,29 @@
interface ServerInfo {
https_port: string;
port: string;
rtmp_port: string;
server_protocol: string;
time_now: string;
timestamp_now: number;
timezone: string;
url: string;
}
interface UserInfo {
active_cons: string;
allowed_output_formats: string[];
auth: number;
created_at: string;
exp_date: string;
is_trial: string;
max_connections: string;
message: string;
password: string;
status: string;
username: string;
}
export interface XtreamAccountInfo {
server_info: ServerInfo;
user_info: UserInfo;
}
@@ -0,0 +1,43 @@
@if (selectedCategory()) {
<div class="grid">
@for (item of paginatedContent(); track $index) {
@let i = $any(item);
<mat-card (click)="onItemClick(item)">
@if (i.poster_url) {
<img
class="stream-icon"
[ngSrc]="i.poster_url"
fill
priority
placeholder
placeholder="data:image/png;base64,iVBORw0K..."
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
/>
}
@let rating = $any(item).rating;
@if (rating) {
<div
class="rating"
[matTooltip]="'XTREAM.IMDB_RATING' | translate"
>
<mat-icon>star</mat-icon>{{ rating }}
</div>
}
<mat-card-actions>
<div class="title">{{ i.title || 'No name' }}</div>
</mat-card-actions>
</mat-card>
}
</div>
<mat-paginator
[length]="totalPages() * limit()"
[pageSize]="limit()"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
aria-label="Select page"
/>
}
@@ -0,0 +1,82 @@
: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;
mat-card-content {
padding: 0;
margin: 0;
}
.stream-icon {
width: 100%;
position: relative !important;
border-radius: 12px;
display: block;
background-color: #f0f0f0
}
.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;
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;
}
}
@@ -0,0 +1,52 @@
import { NgOptimizedImage } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-category-content-view',
templateUrl: './category-content-view.component.html',
styleUrls: ['./category-content-view.component.scss'],
standalone: true,
imports: [
MatCardModule,
MatIcon,
MatPaginatorModule,
MatTooltip,
NgOptimizedImage,
TranslateModule,
],
})
export class CategoryContentViewComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly xtreamStore = inject(XtreamStore);
readonly limit = this.xtreamStore.limit;
readonly pageSizeOptions = [5, 10, 25, 50, 100];
readonly paginatedContent = this.xtreamStore.getPaginatedContent;
readonly selectedCategory = this.xtreamStore.getSelectedCategory;
readonly totalPages = this.xtreamStore.getTotalPages;
onPageChange(event: PageEvent) {
this.xtreamStore.setPage(event.pageIndex + 1);
this.xtreamStore.setLimit(event.pageSize);
localStorage.setItem('xtream-page-size', event.pageSize.toString());
}
onItemClick(item: any) {
console.log(item);
this.xtreamStore.setSelectedItem(item);
this.router.navigate(
[this.xtreamStore.selectedContentType(), item.xtream_id],
{
relativeTo: this.activatedRoute,
}
);
}
}
@@ -0,0 +1,33 @@
:host {
display: block;
height: 100%;
overflow-y: auto;
padding: 10px;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.category-item {
cursor: pointer;
width: auto;
transition: all 0.2s ease;
&.selected {
background-color: rgba(63, 81, 181, 0.1);
border-left: 4px solid #3f51b5;
margin-left: 4px;
}
}
mat-card-content {
overflow-wrap: break-word;
padding: 12px;
}
@media (max-width: 800px) {
.category-item {
width: 100%;
margin: 4px;
}
}
@@ -0,0 +1,91 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Output,
inject,
} from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamCategory } from '../../../../shared/xtream-category.interface';
import { FilterPipe } from '../../shared/pipes/filter.pipe';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-category-view',
standalone: true,
imports: [
FilterPipe,
MatCardModule,
MatIconModule,
MatListModule,
PlaylistErrorViewComponent,
TranslateModule,
MatTooltipModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@let items = xtreamStore.getCategoriesBySelectedType();
@if (items?.length > 0) {
<mat-nav-list>
@for (item of items; track $index) {
<mat-list-item
class="category-item"
[class.selected]="isSelected(item)"
(click)="categoryClicked.emit(item)"
>
{{
item.category_name ||
$any(item).name ||
'No category name'
}}
</mat-list-item>
}
@if (!items?.length) {
<app-playlist-error-view
title="No results"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS'
| translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
}
</mat-nav-list>
} @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',
})
export class CategoryViewComponent {
@Output() categoryClicked = new EventEmitter<XtreamCategory>();
xtreamStore = inject(XtreamStore);
route = inject(ActivatedRoute);
ngOnInit(): void {
const { categoryId } = this.route.snapshot.params;
this.xtreamStore.setSelectedCategory(Number(categoryId));
}
isSelected(item: XtreamCategory): boolean {
const selectedCategory = this.xtreamStore.selectedCategoryId();
const itemId = Number((item as any).category_id || item.id);
return selectedCategory !== null && selectedCategory === itemId;
}
}
+140
View File
@@ -0,0 +1,140 @@
:host {
width: 100%;
display: flex;
flex-direction: column;
overflow: auto;
}
.details {
h2 {
word-wrap: break-word;
margin-top: 0;
}
}
.container {
display: grid;
grid-gap: 20px;
grid-template-columns: minmax(250px, 30%) minmax(300px, 1fr);
grid-template-areas:
"image details"
"seasons seasons";
padding: 20px;
max-width: 100%;
box-sizing: border-box;
height: auto;
min-height: calc(100vh - 73px);
&::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;
}
}
}
.image {
grid-area: image;
margin: 0;
border-radius: 5px;
max-width: 100%;
img {
width: 100%;
height: auto;
border-radius: 5px;
object-fit: cover;
}
.placeholder-cover {
width: 100%;
aspect-ratio: 2/3;
background-color: #999;
border-radius: 5px;
&::after {
content: 'No cover';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
}
app-season-container {
grid-area: seasons;
width: 100%;
}
@media (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-areas:
"image"
"details"
"seasons";
padding: 10px;
}
.image,
.details {
width: 100%;
flex: 1 1 100%;
display: flex;
flex-direction: column;
padding: 0;
img {
max-height: 500px;
object-fit: contain;
border-radius: 0;
}
}
.details {
padding: 0 10px;
}
.youtube-trailer {
width: 100% !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: 100%;
max-width: 650px;
margin: 0 auto;
}
@@ -0,0 +1,75 @@
<div class="favorites-container">
<div class="header">
<h2>{{ 'CHANNELS.FAVORITES' | translate }}</h2>
</div>
<mat-tab-group>
<mat-tab label="Live TV">
<ng-container
*ngTemplateOutlet="itemsGrid; context: { type: 'live' }"
>
</ng-container>
</mat-tab>
<mat-tab label="Movies">
<ng-container
*ngTemplateOutlet="itemsGrid; context: { type: 'movie' }"
>
</ng-container>
</mat-tab>
<mat-tab label="Series">
<ng-container
*ngTemplateOutlet="itemsGrid; context: { type: 'series' }"
>
</ng-container>
</mat-tab>
</mat-tab-group>
<ng-template #itemsGrid let-type="type">
@if ((favorites$ | async | filterByType: type).length) {
<div class="items-grid">
@for (
item of favorites$ | async | filterByType: type;
track $any(item).id
) {
<mat-card class="item-card" (click)="openItem(item)">
<img
[src]="
item.poster_url ||
'assets/default-' + type + '.png'
"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
[alt]="item.title"
class="item-image"
/>
<mat-card-content>
<h3>{{ item.title }}</h3>
<p class="added-date">
Added:
{{ item.added_at | date: 'd MMMM, y, hh:mm' }}
</p>
</mat-card-content>
<button
mat-icon-button
color="warn"
class="favorite-button"
(click)="
$event.stopPropagation();
removeFromFavorites(item)
"
>
<mat-icon>favorite</mat-icon>
</button>
</mat-card>
}
</div>
} @else {
<div class="no-items">
<mat-icon>favorite_border</mat-icon>
<p>No favorite {{ type }} items</p>
</div>
}
</ng-template>
</div>
@@ -0,0 +1,91 @@
.favorites-container {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
padding: 16px;
width: 100%;
box-sizing: border-box;
margin: 0;
}
.item-card {
position: relative;
cursor: pointer;
transition: transform 0.2s;
height: 100%;
display: flex;
flex-direction: column;
&:hover {
transform: scale(1.02);
}
}
.item-image {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
}
.favorite-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
}
mat-card-content {
padding: 12px;
flex-grow: 1;
h3 {
margin: 0;
font-weight: normal;
font-size: 14px;
line-height: 1.2;
max-height: 2.4em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: normal;
}
}
.added-date {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin: 4px 0 0 0;
}
.no-items {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 1rem;
}
p {
font-size: 1.1rem;
margin: 0;
}
}
@@ -0,0 +1,73 @@
import { AsyncPipe, DatePipe, NgTemplateOutlet } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { FilterByTypePipe } from '../../shared/pipes/filter-by-type.pipe';
import { selectActivePlaylist } from '../../state/selectors';
import { FavoriteItem } from '../services/favorite-item.interface';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-favorites',
standalone: true,
imports: [
AsyncPipe,
DatePipe,
FilterByTypePipe,
MatCardModule,
MatIcon,
MatIconButton,
MatTabsModule,
NgTemplateOutlet,
TranslateModule,
],
templateUrl: './favorites.component.html',
styleUrl: './favorites.component.scss',
})
export class FavoritesComponent implements OnInit {
private favoritesService = inject(FavoritesService);
private router = inject(Router);
private store = inject(Store);
private xtreamStore = inject(XtreamStore);
favorites$: Observable<FavoriteItem[]>;
ngOnInit() {
const playlistId = this.store.selectSignal(selectActivePlaylist)()._id;
this.favorites$ = this.favoritesService.getFavorites(playlistId);
}
async removeFromFavorites(item: any) {
await this.favoritesService.removeFromFavorites(
item.id,
item.playlist_id
);
// Refresh favorites after removal
const playlistId = this.store.selectSignal(selectActivePlaylist)()._id;
this.favorites$ = this.favoritesService.getFavorites(playlistId);
}
openItem(item: any) {
const type = item.type === 'movie' ? 'vod' : item.type;
this.xtreamStore.setSelectedContentType(type);
this.xtreamStore.setSelectedContentType(type);
if (type === 'live') {
this.router.navigate(['/xtreams', item.playlist_id, 'live']);
} else {
this.router.navigate([
'/xtreams',
item.playlist_id,
item.category_id,
type,
item.xtream_id,
]);
}
}
}
@@ -0,0 +1,89 @@
<div
[class]="
player === 'videojs' || player === 'html5'
? 'itv-container'
: 'itv-container-epg'
"
>
<div class="sidebar">
<div class="sidebar-content">
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
spellcheck="false"
type="search"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</mat-form-field>
</div>
<mat-nav-list>
<cdk-virtual-scroll-viewport
[itemSize]="48"
class="scroll-viewport-portals"
>
<mat-list-item
*cdkVirtualFor="
let item of channels()
| filterBy: searchString() : 'title';
trackBy: trackBy;
templateCacheSize: 0
"
(click)="playLive(item)"
[activated]="
xtreamStore.selectedItem()?.xtream_id ===
item.xtream_id
"
>
<img
matListItemAvatar
class="stream-icon"
*ngIf="item.poster_url"
[src]="item.poster_url"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
/>
<div class="title" matListItemTitle>
{{ item.title }}
</div>
<button
mat-icon-button
matListItemMeta
(click)="toggleFavorite($event, item)"
>
<mat-icon>
{{
favorites.get(item.xtream_id)
? 'star'
: 'star_outline'
}}
</mat-icon>
</button>
</mat-list-item>
</cdk-virtual-scroll-viewport>
</mat-nav-list>
</div>
</div>
@if (player === 'videojs' || player === 'html5') {
@if (streamUrl) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
</div>
}
}
@if (streamUrl) {
<div class="epg">
<div class="epg-content">
@if (epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
}
</div>
</div>
} @else {
<div class="no-channel-selected">Please select a channel</div>
}
</div>
@@ -0,0 +1,109 @@
@use '@angular/material' as mat;
:host {
width: 100%;
}
.itv-container {
display: grid;
grid-template-columns: 330px 1fr 1fr;
grid-template-rows: 50% 50%;
gap: 0 0;
grid-auto-flow: row;
grid-template-areas:
'sidebar video-player video-player'
'sidebar epg epg';
}
.itv-container-epg {
display: grid;
grid-template-columns: 330px 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 0 0;
grid-auto-flow: row;
grid-template-areas:
'sidebar epg epg'
'sidebar epg epg'
'sidebar epg epg';
height: 100%;
}
.sidebar { grid-area: sidebar; }
.video-player { grid-area: video-player; }
.epg { grid-area: epg; }
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -25px;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
.epg-content {
height: calc(50vh - 20px);
overflow-y: auto;
border-right: #111 1px solid;
padding-bottom: 20px;
}
.sidebar-content {
height: 100%;
overflow-y: auto;
border-right: #111 1px solid;
overflow-x: hidden;
mat-nav-list {
width: 100%;
overflow: hidden;
display: block;
}
}
.no-channel-selected {
margin: 10px;
}
mat-list-option {
width: 320px;
}
::ng-deep {
.search-bar {
div {
border-radius: 20px;
}
}
}
.scroll-viewport-portals {
min-height: calc(100vh - 59px) !important;
width: 100%;
}
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(100%); // Account for avatar and favorite button
min-width: 0;
flex: 1;
}
mat-list-item {
width: 325px;
min-width: 0; // Prevents flex items from overflowing
box-sizing: border-box;
::ng-deep .mdc-list-item__content {
overflow: hidden;
flex: 1;
}
}
@@ -0,0 +1,181 @@
import { ScrollingModule } from '@angular/cdk/scrolling';
import { NgIf } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { TranslateModule } from '@ngx-translate/core';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
} from '../../../../shared/ipc-commands';
import { XtreamItem } from '../../../../shared/xtream-item.interface';
import { EpgViewComponent } from '../../portals/epg-view/epg-view.component';
import { WebPlayerViewComponent } from '../../portals/web-player-view/web-player-view.component';
import { DataService } from '../../services/data.service';
import { DatabaseService } from '../../services/database.service'; // Add this import
import { SettingsStore } from '../../services/settings-store.service';
import { VideoPlayer } from '../../settings/settings.interface';
import { ExternalPlayerInfoDialogComponent } from '../../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import { FilterPipe } from '../../shared/pipes/filter.pipe';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../player-dialog/player-dialog.component';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../xtream.store';
@Component({
standalone: true,
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
styleUrls: ['./live-stream-layout.component.scss'],
imports: [
EpgViewComponent,
FilterPipe,
FormsModule,
MatIconButton,
MatListModule,
MatIcon,
MatInputModule,
MatFormFieldModule,
NgIf,
ScrollingModule,
WebPlayerViewComponent,
TranslateModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LiveStreamLayoutComponent implements OnInit {
private settingsStore = inject(SettingsStore);
private dialog = inject(MatDialog);
private dataService = inject(DataService);
private favoritesService = inject(FavoritesService);
private dbService = inject(DatabaseService); // Add this injection
private readonly settings = this.settingsStore.getSettings();
readonly xtreamStore = inject(XtreamStore);
readonly channels = this.xtreamStore.liveStreams;
readonly epgItems = this.xtreamStore.epgItems;
private readonly hideExternalInfoDialog =
this.xtreamStore.hideExternalInfoDialog;
private readonly selectedContentType = this.xtreamStore.selectedContentType;
player: VideoPlayer = VideoPlayer.VideoJs;
streamUrl: string;
searchString = signal<string>('');
favorites = new Map<number, boolean>();
trackBy(_index: number, item: XtreamItem) {
return item.xtream_id;
}
ngOnInit() {
const playlist = this.xtreamStore.currentPlaylist();
if (playlist) {
this.favoritesService
.getFavorites(playlist.id)
.subscribe((favorites) => {
// Map using content.id instead of xtream_id
favorites.forEach((fav: any) => {
this.favorites.set(fav.xtream_id, true);
});
});
}
}
playLive(item: any) {
const { serverUrl, username, password } =
this.xtreamStore.currentPlaylist();
const streamUrl = `${serverUrl}/live/${username}/${password}/${item.xtream_id}.m3u8`;
// TODO: offer option to select TS or m3u8
this.openPlayer(streamUrl, item.title, item.poster_url);
this.xtreamStore.setSelectedItem(item);
this.xtreamStore.loadEpg();
}
openPlayer(streamUrl: string, title: string, thumbnail: string) {
this.streamUrl = streamUrl;
this.player = this.settings()?.player ?? VideoPlayer.VideoJs;
if (this.player === VideoPlayer.MPV) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: this.settings()?.mpvPlayerPath,
title,
thumbnail,
});
} else if (this.player === VideoPlayer.VLC) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: this.settings()?.vlcPlayerPath,
});
} else {
if (this.selectedContentType() !== 'live') {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title },
width: '80%',
}
);
}
}
}
async toggleFavorite(event: Event, item: any): Promise<void> {
event.stopPropagation();
const playlist = this.xtreamStore.currentPlaylist();
// Update UI state immediately
const currentFavoriteState =
this.favorites.get(item.xtream_id) || false;
this.favorites.set(item.xtream_id, !currentFavoriteState);
try {
const db = await this.dbService.getConnection();
const content: any = await db.select(
'SELECT id FROM content WHERE xtream_id = ?',
[item.xtream_id]
);
if (!content || content.length === 0) {
console.error('Content not found in database');
// Revert UI state on error
this.favorites.set(item.xtream_id, currentFavoriteState);
return;
}
const contentId = content[0].id;
if (!currentFavoriteState) {
await this.favoritesService.addToFavorites({
content_id: contentId,
playlist_id: playlist.id,
});
} else {
await this.favoritesService.removeFromFavorites(
contentId,
playlist.id
);
}
} catch (error) {
console.error('Error toggling favorite:', error);
// Revert UI state on error
this.favorites.set(item.xtream_id, currentFavoriteState);
}
}
}
@@ -0,0 +1,56 @@
import { CommonModule } from '@angular/common';
import { Component, input } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
@Component({
selector: 'app-loading-overlay',
standalone: true,
imports: [MatProgressBarModule, CommonModule],
template: `
<div class="overlay">
<div class="progress-container">
<h3>Loading playlist...</h3>
@if (current() !== 0 && total() !== 0) {
<mat-progress-bar
mode="determinate"
[value]="(current() / total()) * 100"
/>
<p>{{ current() }} / {{ total() }}</p>
} @else {
<mat-progress-bar mode="indeterminate" />
}
</div>
</div>
`,
styles: [
`
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.progress-container {
padding: 2rem;
border-radius: 8px;
min-width: 300px;
text-align: center;
h3 {
margin-top: 0;
}
}
`,
],
})
export class LoadingOverlayComponent {
current = input(0);
total = input(0);
}
@@ -0,0 +1,90 @@
<mat-nav-list>
<a
mat-list-item
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
>
<mat-icon matListItemIcon>home</mat-icon>
<span matListItemTitle>Home</span>
</a>
<a
mat-list-item
(click)="xtreamStore.setSelectedContentType('live')"
routerLink="./live"
[class.active]="isContentTypeActive('live')"
>
<mat-icon matListItemIcon>live_tv</mat-icon>
<span matListItemTitle>Live TV</span>
</a>
<a
mat-list-item
(click)="xtreamStore.setSelectedContentType('vod')"
routerLink="./vod"
[class.active]="isContentTypeActive('vod')"
>
<mat-icon matListItemIcon>movie</mat-icon>
<span matListItemTitle>Movies</span>
</a>
<a
mat-list-item
routerLink="./series"
(click)="xtreamStore.setSelectedContentType('series')"
[class.active]="isContentTypeActive('series')"
>
<mat-icon matListItemIcon>tv</mat-icon>
<span matListItemTitle>TV Series</span>
</a>
<mat-divider />
<a
mat-list-item
routerLink="./search"
routerLinkActive="active"
(click)="xtreamStore.setSelectedContentType(undefined)"
>
<mat-icon matListItemIcon>search</mat-icon>
<span matListItemTitle>Search</span>
</a>
<a
mat-list-item
routerLink="./recent"
routerLinkActive="active"
(click)="xtreamStore.setSelectedContentType(undefined)"
>
<mat-icon matListItemIcon>history</mat-icon>
<span matListItemTitle>Recent</span>
</a>
<a
mat-list-item
routerLink="./favorites"
routerLinkActive="active"
(click)="xtreamStore.setSelectedContentType(undefined)"
>
<mat-icon matListItemIcon>favorite</mat-icon>
<span matListItemTitle>Favorites</span>
</a>
<mat-divider />
<a mat-list-item (click)="openSettings()">
<mat-icon matListItemIcon>settings</mat-icon>
<span matListItemTitle>{{ 'MENU.SETTINGS' | translate }}</span>
</a>
<a mat-list-item (click)="openAccountInfo()">
<mat-icon matListItemIcon>account_circle</mat-icon>
<span matListItemTitle>Account Info</span>
</a>
<a mat-list-item (click)="openPlaylistInfo()">
<mat-icon matListItemIcon>info</mat-icon>
<span matListItemTitle>Playlist Info</span>
</a>
</mat-nav-list>
@@ -0,0 +1,28 @@
:host {
padding: 10px;
display: block;
height: 100%;
}
.mat-nav-list {
padding-top: 0;
}
.active {
transition: all 0.2s ease;
background: rgba(63, 81, 181, 0.12);
color: #3f51b5;
border-left: 4px solid #3f51b5;
mat-icon {
color: #3f51b5;
}
}
mat-divider {
margin: 8px 0;
}
mat-icon {
margin-right: 8px;
}
@@ -0,0 +1,68 @@
import { Component, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistInfoComponent } from '../../home/recent-playlists/playlist-info/playlist-info.component';
import { SettingsComponent } from '../../settings/settings.component';
import { selectActivePlaylist } from '../../state/selectors';
import { AccountInfoComponent } from '../account-info/account-info.component';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-navigation',
standalone: true,
imports: [
MatListModule,
MatIconModule,
RouterLink,
RouterLinkActive,
TranslateModule,
],
templateUrl: './navigation.component.html',
styleUrl: './navigation.component.scss',
})
export class NavigationComponent {
private readonly dialog = inject(MatDialog);
private readonly store = inject(Store);
readonly xtreamStore = inject(XtreamStore);
private readonly currentPlaylist =
this.store.selectSignal(selectActivePlaylist);
openAccountInfo() {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data: {
vodStreamsCount: this.xtreamStore.vodStreams().length,
liveStreamsCount: this.xtreamStore.liveStreams().length,
seriesCount: this.xtreamStore.serialStreams().length,
},
});
}
openSettings() {
this.dialog.open(SettingsComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data: {
isDialog: true,
},
});
}
isContentTypeActive(type: 'live' | 'vod' | 'series'): boolean {
return this.xtreamStore.selectedContentType() === type;
}
openPlaylistInfo() {
this.dialog.open(PlaylistInfoComponent, {
data: this.currentPlaylist(),
});
}
}
@@ -0,0 +1,25 @@
<h2 mat-dialog-title>{{ title }}</h2>
<mat-dialog-content class="content">
<app-web-player-view [streamUrl]="streamUrl" />
<mat-form-field class="link-input">
<input
matInput
formControlName="streamUrl"
[value]="streamUrl"
disabled
/>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions class="align-actions">
<button
mat-button
[cdkCopyToClipboard]="streamUrl"
(click)="showCopyNotification()"
>
<mat-icon>content_copy</mat-icon>
{{ 'PORTALS.COPY_STREAM_URL' | translate }}
</button>
<button mat-button mat-dialog-close color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,22 @@
@use '@angular/material' as mat;
.content {
overflow: hidden;
padding: 10px !important;
}
.link-input {
width: 98%;
padding-top: 5px;
@include mat.form-field-density(-5);
}
.align-actions {
justify-content: space-between;
}
mat-dialog-content {
.video-js {
height: 500px !important;
}
}
@@ -0,0 +1,55 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { Component, Inject, ViewEncapsulation } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { WebPlayerViewComponent } from '../../portals/web-player-view/web-player-view.component';
export interface PlayerDialogData {
streamUrl: string;
title: string;
}
@Component({
templateUrl: './player-dialog.component.html',
standalone: true,
imports: [
ClipboardModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslateModule,
WebPlayerViewComponent,
],
styleUrl: './player-dialog.component.scss',
encapsulation: ViewEncapsulation.None,
})
export class PlayerDialogComponent {
title: string;
streamUrl: string;
constructor(
@Inject(MAT_DIALOG_DATA) data: PlayerDialogData,
private snackBar: MatSnackBar,
private translateService: TranslateService
) {
this.streamUrl = data.streamUrl;
this.title = data.title;
}
showCopyNotification() {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
}
@@ -0,0 +1,33 @@
<div class="expired-account">
@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>
<h3>{{ title }}</h3>
{{ description }}
</div>
@if (showActionButtons) {
<div>
<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>
@@ -0,0 +1,28 @@
.expired-account {
display: flex;
flex-direction: column;
padding: 10px;
align-items: center;
gap: 10px;
text-align: center;
img {
filter: invert(80%);
}
}
.icon {
font-size: 64px;
height: 64px;
width: 64px;
}
::ng-deep {
.dark-theme {
.expired-account {
img {
filter: invert(10%) !important;
}
}
}
}
@@ -0,0 +1,59 @@
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 { PlaylistInfoComponent } from '../../home/recent-playlists/playlist-info/playlist-info.component';
import { DatabaseService } from '../../services/database.service';
import { DialogService } from '../../services/dialog.service';
import * as PlaylistActions from '../../state/actions';
import { selectCurrentPlaylist } from '../../state/selectors';
@Component({
standalone: true,
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 {
private databaseService = inject(DatabaseService);
private dialog = inject(MatDialog);
private dialogService = inject(DialogService);
private router = inject(Router);
private store = inject(Store);
private translate = inject(TranslateService);
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()._id),
});
}
removePlaylist(playlistId: string): void {
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
this.router.navigate(['/']);
this.databaseService.deletePlaylist(playlistId);
}
}
@@ -0,0 +1,47 @@
@let items = recentItems();
<div class="recent-container">
<div class="header">
<h2>Recently Viewed</h2>
@if (items.length) {
<button mat-button color="warn" (click)="clearHistory()">
<mat-icon>delete</mat-icon>
Clear History
</button>
}
</div>
@if (items.length) {
<div class="items-grid">
@for (item of items; track $index) {
<mat-card class="item-card" (click)="openItem(item)">
<img
[src]="
item.poster_url ||
'./assets/images/default-poster.png'
"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
[alt]="item.title"
class="poster"
/>
<div class="item-info">
<h3>{{ item.title }}</h3>
<p [class]="item.type" class="type-badge">
{{ item.type | titlecase }}
</p>
<p class="viewed-at">
{{ item.viewed_at | date: 'd MMMM, y, hh:mm' }}
</p>
</div>
</mat-card>
}
</div>
} @else {
<div class="no-items">
<mat-icon>history</mat-icon>
<p>No recently viewed items</p>
</div>
}
</div>
@@ -0,0 +1,83 @@
.recent-container {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.item-card {
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.02);
}
}
.poster {
width: 100%;
height: 280px;
object-fit: cover;
}
.item-info {
padding: 16px;
h3 {
margin: 0;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
margin: 8px 0;
}
.viewed-at {
font-size: 12px;
}
}
.no-items {
text-align: center;
padding: 40px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
}
}
.type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-transform: uppercase;
&.live {
background: #e53935;
color: white;
}
&.movie {
background: #1e88e5;
color: white;
}
&.series {
background: #43a047;
color: white;
}
}
@@ -0,0 +1,40 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-recently-viewed',
standalone: true,
imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule],
templateUrl: './recently-viewed.component.html',
styleUrl: './recently-viewed.component.scss',
})
export class RecentlyViewedComponent {
private xtreamStore = inject(XtreamStore);
private activatedRoute = inject(ActivatedRoute);
private router = inject(Router);
readonly recentItems = this.xtreamStore.recentItems;
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
constructor() {
this.xtreamStore.loadRecentItems(this.currentPlaylist);
}
clearHistory() {
this.xtreamStore.clearRecentItems(this.xtreamStore.currentPlaylist);
}
openItem(item: any) {
const type = item.type === 'movie' ? 'vod' : item.type;
this.xtreamStore.setSelectedContentType(type);
this.router.navigate(['..', item.category_id, type, item.xtream_id], {
relativeTo: this.activatedRoute,
});
}
}
@@ -0,0 +1,84 @@
<div class="search-page">
<div class="header">
<h2>Search</h2>
</div>
<div class="search-container">
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search content</mat-label>
<input
matInput
spellcheck="false"
autofocus
[(ngModel)]="searchTerm"
(ngModelChange)="onSearch()"
/>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<div class="type-filters">
<mat-checkbox
[(ngModel)]="filters.live"
(ngModelChange)="onSearch()"
>
Live TV
</mat-checkbox>
<mat-checkbox
[(ngModel)]="filters.movie"
(ngModelChange)="onSearch()"
>
Movies
</mat-checkbox>
<mat-checkbox
[(ngModel)]="filters.series"
(ngModelChange)="onSearch()"
>
Series
</mat-checkbox>
</div>
@if (xtreamStore.searchResults().length > 0) {
<div class="results-count">
Found {{ xtreamStore.searchResults().length }} results
</div>
}
</div>
<div class="results-container">
@if (xtreamStore.searchResults().length > 0) {
<div class="results-grid">
@for (item of xtreamStore.searchResults(); track item.id) {
<mat-card (click)="selectItem(item)">
<mat-card-content>
@if (item.poster_url) {
<img
[src]="item.poster_url"
[alt]="item.title"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
class="poster"
/>
}
@if (!item.poster_url) {
<div class="poster-placeholder">
<mat-icon>movie</mat-icon>
</div>
}
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<div class="type-badge" [class]="item.type">
{{ item.type }}
</div>
</mat-card-content>
</mat-card>
}
</div>
} @else if (searchTerm) {
<div class="no-items">
<mat-icon>search_off</mat-icon>
<p>No results found for "{{ searchTerm }}"</p>
</div>
}
</div>
</div>
@@ -0,0 +1,121 @@
.search-page {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.search-container {
flex-shrink: 0;
.search-field {
width: 100%;
max-width: 600px;
}
.type-filters {
margin-top: 1rem;
mat-checkbox {
margin-right: 1rem;
}
}
.results-count {
margin-top: 1rem;
font-size: 0.9rem;
}
}
.results-container {
flex: 1;
overflow-y: auto;
padding: 0 1rem;
.results-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(200px, 1fr)
);
gap: 1rem;
padding-bottom: 1rem;
mat-card {
cursor: pointer;
.poster,
.poster-placeholder {
border-radius: 13px;
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
margin-bottom: 0.5rem;
}
.poster-placeholder {
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
}
h3 {
margin: 0.5rem 0;
font-size: 1rem;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
font-size: 0.8rem;
color: rgba(0, 0, 0, 0.6);
margin: 0.5rem 0;
}
.type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
text-transform: uppercase;
&.live {
background: #e53935;
color: white;
}
&.movie {
background: #1e88e5;
color: white;
}
&.series {
background: #43a047;
color: white;
}
}
}
}
}
.no-items {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 1rem;
}
p {
font-size: 1.1rem;
margin: 0;
}
}
@@ -0,0 +1,65 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, Router } from '@angular/router';
import { XtreamItem } from '../../../../shared/xtream-item.interface';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-search-results',
standalone: true,
imports: [
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatCheckboxModule,
FormsModule,
],
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
})
export class SearchResultsComponent {
readonly xtreamStore = inject(XtreamStore);
readonly router = inject(Router);
readonly activatedRoute = inject(ActivatedRoute);
searchTerm = '';
filters = {
live: true,
movie: true,
series: true,
};
onSearch() {
if (this.searchTerm.length >= 3) {
const types = Object.entries(this.filters)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, enabled]) => enabled)
.map(([type]) => type);
this.xtreamStore.searchContent({ term: this.searchTerm, types });
}
}
selectItem(item: XtreamItem) {
const type = item.type === 'movie' ? 'vod' : item.type;
this.xtreamStore.resetSearchResults();
this.xtreamStore.setSelectedContentType(type);
if (item.type === 'live') {
this.router.navigate(['..', 'live'], {
relativeTo: this.activatedRoute,
});
} else {
this.router.navigate(
['..', item.category_id, type, item.xtream_id],
{
relativeTo: this.activatedRoute,
}
);
}
}
}
@@ -0,0 +1,35 @@
<h3>Seasons and Episodes</h3>
@if (selectedSeason) {
<button mat-button class="back-button" (click)="selectedSeason = undefined">
<mat-icon>arrow_back</mat-icon>
<span>Back to seasons</span>
</button>
}
<div class="seasons-container">
@if (!selectedSeason) {
@for (season of seasons | keyvalue: compareSeasons; track $index) {
<mat-card class="season-item" (click)="selectSeason(season.key)">
<mat-card-content>Season {{ season.key }}</mat-card-content>
</mat-card>
}
} @else {
@for (episode of seasons[selectedSeason]; track $index) {
<mat-card class="episode-item" (click)="selectEpisode(episode)">
@if (episode.info?.movie_image) {
<mat-card-content
><img
[src]="episode.info.movie_image"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
class="episode-cover"
/></mat-card-content>
}
<div class="episode-title">
{{ episode.episode_num }}. {{ episode.title }}
</div>
</mat-card>
}
}
</div>
@@ -0,0 +1,39 @@
:host {
margin: 10px;
display: block;
padding: 20px 0;
}
.seasons-container {
width: 100%;
display: flex;
flex-flow: wrap;
gap: 10px;
}
.season-item,
.episode-item {
min-height: 100px;
justify-content: center;
width: 150px;
cursor: pointer;
display: block;
}
.episode-item {
mat-card-content {
padding: 0;
}
mat-card-title {
padding: 10px;
}
.episode-title {
padding: 5px;
}
.episode-cover {
width: 100%;
}
}
@@ -0,0 +1,33 @@
import { KeyValuePipe } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { XtreamSerieEpisode } from '../../../../shared/xtream-serie-details.interface';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
standalone: true,
imports: [KeyValuePipe, MatCardModule, MatIcon, MatButton],
})
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);
}
}
@@ -0,0 +1,85 @@
@let item = selectedItem();
@if (item) {
<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"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
/>
}
</div>
<div class="details">
<h2>{{ item.info?.name }}</h2>
@if (item.info?.plot) {
<label>
{{ item.info.plot }}
</label>
}
<label *ngIf="item.info?.releaseDate">
<div class="label">
{{ 'XTREAM.RELEASE_DATE' | translate }}:
</div>
{{ item.info.releaseDate }}
</label>
<label *ngIf="item.info?.genre">
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item.info.genre }}
</label>
<label *ngIf="item.info?.rating">
<div class="label">{{ 'XTREAM.RATING' | translate }}:</div>
{{ item.info?.rating }}
</label>
<div *ngIf="item.info?.cast">
<div class="label">{{ 'XTREAM.CAST' | translate }}:</div>
{{ item.info.cast }}
</div>
<label *ngIf="item.info?.director">
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item.info.director }}
</label>
<label *ngIf="item.info?.episode_run_time">
<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>
<app-season-container
[seasons]="item.episodes"
(episodeClicked)="playEpisode($event)"
/>
</div>
}
@@ -0,0 +1,123 @@
import { NgIf } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
} from '../../../../shared/ipc-commands';
import { XtreamSerieEpisode } from '../../../../shared/xtream-serie-details.interface';
import { DataService } from '../../services/data.service';
import { SettingsStore } from '../../services/settings-store.service';
import { VideoPlayer } from '../../settings/settings.interface';
import { ExternalPlayerInfoDialogComponent } from '../../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import {
selectActivePlaylist,
selectCurrentPlaylist,
} from '../../state/selectors';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../player-dialog/player-dialog.component';
import { SeasonContainerComponent } from '../season-container/season-container.component';
import { XtreamStore } from '../xtream.store';
@Component({
selector: 'app-serial-details',
templateUrl: './serial-details.component.html',
styleUrls: ['../detail-view.scss'],
standalone: true,
imports: [
MatButton,
MatIcon,
NgIf,
SeasonContainerComponent,
TranslateModule,
],
})
export class SerialDetailsComponent {
private dataService = inject(DataService);
private dialog = inject(MatDialog);
private readonly store = inject(Store);
readonly currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
private readonly route = inject(ActivatedRoute);
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
readonly selectedItem = this.xtreamStore.selectedItem;
readonly settings = this.settingsStore.getSettings();
readonly selectedContentType = this.xtreamStore.selectedContentType;
readonly isFavorite = this.xtreamStore.isFavorite;
private readonly hideExternalInfoDialog =
this.xtreamStore.hideExternalInfoDialog;
ngOnInit(): void {
const { categoryId, serialId } = this.route.snapshot.params;
this.xtreamStore.getSerialDetails(serialId).then((currentSerial) => {
this.xtreamStore.setSelectedCategory(Number(categoryId));
this.xtreamStore.setSelectedItem({
...currentSerial,
series_id: serialId,
});
});
this.xtreamStore.checkFavoriteStatus();
}
playEpisode(episode: XtreamSerieEpisode) {
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
const currentPlaylist = this.store.selectSignal(selectActivePlaylist);
const { serverUrl, username, password } = currentPlaylist();
const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`;
this.openPlayer(streamUrl, episode.title);
}
openPlayer(streamUrl: string, title: string) {
const player = this.settings()?.player ?? VideoPlayer.VideoJs;
if (player === VideoPlayer.MPV) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: this.settings()?.mpvPlayerPath,
title: this.xtreamStore.selectedItem().info.name,
thumbnail: this.xtreamStore.selectedItem().info.cover,
});
} else if (player === VideoPlayer.VLC) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: this.settings()?.vlcPlayerPath,
});
} else {
if (this.selectedContentType() !== 'live') {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
}
);
}
}
}
private addToRecentlyViewed(xtreamId: number) {
this.xtreamStore.addRecentItem({
contentId: xtreamId,
playlist: this.xtreamStore.currentPlaylist,
});
}
toggleFavorite() {
this.xtreamStore.toggleFavorite();
}
}
@@ -0,0 +1,9 @@
export interface FavoriteItem {
content_id: number;
playlist_id: string;
type: 'live' | 'vod' | 'series';
title: string;
stream_icon?: string;
poster_url?: string;
added_at?: string;
}
@@ -0,0 +1,65 @@
import { inject, Injectable } from '@angular/core';
import { from, map, mergeMap, Observable } from 'rxjs';
import { DatabaseService } from '../../services/database.service';
import { FavoriteItem } from './favorite-item.interface';
@Injectable({
providedIn: 'root',
})
export class FavoritesService {
private dbService = inject(DatabaseService);
async addToFavorites(item: {
content_id: number;
playlist_id: string;
}): Promise<void> {
const db = await this.dbService.getConnection();
await db.execute(
`INSERT INTO favorites (content_id, playlist_id)
VALUES (?, ?)`,
[item.content_id, item.playlist_id]
);
}
async removeFromFavorites(
contentId: number,
playlistId: string
): Promise<void> {
const db = await this.dbService.getConnection();
await db.execute(
`DELETE FROM favorites
WHERE content_id = ? AND playlist_id = ?`,
[contentId, playlistId]
);
}
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
const db = await this.dbService.getConnection();
const result = await db.select<any[]>(
`SELECT COUNT(*) as count
FROM favorites
WHERE content_id = ? AND playlist_id = ?`,
[contentId, playlistId]
);
return result[0].count > 0;
}
getFavorites(playlistId: string): Observable<FavoriteItem[]> {
return from(this.dbService.getConnection()).pipe(
mergeMap(async (db) => {
return await db.select<FavoriteItem[]>(
`SELECT
c.*,
f.playlist_id,
f.added_at
FROM favorites f
JOIN content c ON f.content_id = c.id
WHERE f.playlist_id = ?
ORDER BY f.added_at DESC`,
[playlistId]
);
}),
map((results) => results as FavoriteItem[])
);
}
}
@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({ name: 'safe', standalone: true })
export class SafePipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
transform(url: string) {
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
}
}
@@ -0,0 +1,139 @@
@let item = selectedItem();
@if (item && item.info) {
<div
class="container"
[style.background]="
item?.info?.backdrop_path && item?.info?.backdrop_path.length > 0
? settings().theme === 'DARK_THEME'
? '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'
: 'linear-gradient(to top, rgba(255,255,255,1) 0%, rgba(255,255,255,0.5) 100%), url(' +
item?.info?.backdrop_path[0] +
') repeat-x'
: 'none'
"
>
<div class="image">
@if (item.info?.movie_image) {
<img
[src]="item.info?.movie_image"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
/>
} @else {
<img src="./assets/images/default-poster.png" />
}
</div>
<div class="details">
<h2>{{ item?.info?.name || item?.movie_data?.name }}</h2>
@if (item?.info?.description || item?.info?.plot) {
<label>
{{ item?.info?.description || 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?.country) {
<label>
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
{{ item?.info?.country }}
</label>
}
@if (item?.info?.actors || item?.info?.cast) {
<div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ item?.info?.actors || item?.info?.cast }}
</div>
}
@if (item?.info?.director) {
<label>
<div class="label">
{{ 'XTREAM.DIRECTOR' | translate }}:
</div>
{{ item?.info?.director }}
</label>
}
@if (item?.info?.duration) {
<label>
<div class="label">
{{ 'XTREAM.DURATION' | translate }}:
</div>
{{ item?.info?.duration }}
</label>
}
@if (item?.info?.rating_imdb) {
<label>
<div class="label">
{{ 'XTREAM.IMDB_RATING' | translate }}:
</div>
{{ item?.info?.rating_imdb }}
</label>
}
@if (item?.info?.rating_kinopoisk) {
<label>
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ item?.info?.rating_kinopoisk }}
</label>
}
<div class="action-buttons">
<button mat-flat-button color="accent" (click)="playVod(item)">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}</button
>&nbsp;
@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-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
</div>
</div>
</div>
@if (item?.info?.youtube_trailer) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<iframe
width="100%"
height="450"
[src]="
'https://www.youtube.com/embed/' +
item.info?.youtube_trailer | safe
"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
}
}
@@ -0,0 +1,124 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
} from '../../../../shared/ipc-commands';
import { XtreamVodDetails } from '../../../../shared/xtream-vod-details.interface';
import { DataService } from '../../services/data.service';
import { SettingsStore } from '../../services/settings-store.service';
import { VideoPlayer } from '../../settings/settings.interface';
import { ExternalPlayerInfoDialogComponent } from '../../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import { selectActivePlaylist } from '../../state/selectors';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../player-dialog/player-dialog.component';
import { XtreamStore } from '../xtream.store';
import { SafePipe } from './safe.pipe';
@Component({
templateUrl: './vod-details.component.html',
styleUrls: ['../detail-view.scss'],
standalone: true,
imports: [
MatButton,
MatIcon,
SafePipe,
TranslateModule,
MatProgressSpinnerModule,
],
})
export class VodDetailsComponent implements OnInit, OnDestroy {
private dataService = inject(DataService);
private dialog = inject(MatDialog);
private settingsStore = inject(SettingsStore);
private route = inject(ActivatedRoute);
private store = inject(Store);
private readonly xtreamStore = inject(XtreamStore);
readonly settings = this.settingsStore.getSettings();
readonly selectedContentType = this.xtreamStore.selectedContentType();
readonly hideExternalInfoDialog = this.xtreamStore.hideExternalInfoDialog;
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedItem = this.xtreamStore.selectedItem;
ngOnInit(): void {
const { categoryId, vodId } = this.route.snapshot.params;
this.xtreamStore.getVodDetails(vodId).then((currentVod) => {
this.xtreamStore.setSelectedCategory(Number(categoryId));
this.xtreamStore.setSelectedItem(currentVod);
});
this.xtreamStore.checkFavoriteStatus();
}
ngOnDestroy() {
this.xtreamStore.setSelectedItem(null);
}
playVod(vodItem: XtreamVodDetails) {
this.addToRecentlyViewed(vodItem);
const currentPlaylist = this.store.selectSignal(selectActivePlaylist);
const { serverUrl, username, password } = currentPlaylist();
const streamUrl = `${serverUrl}/movie/${username}/${password}/${vodItem.movie_data.stream_id}.${vodItem.movie_data.container_extension}`;
this.openPlayer(
streamUrl,
vodItem.info.name ?? vodItem?.movie_data?.name,
vodItem.info.movie_image
);
}
openPlayer(streamUrl: string, title: string, thumbnail: string) {
const player = this.settings()?.player ?? VideoPlayer.VideoJs;
if (player === VideoPlayer.MPV) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: this.settings()?.mpvPlayerPath,
title,
thumbnail,
});
} else if (player === VideoPlayer.VLC) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: this.settings()?.vlcPlayerPath,
});
} else {
if (this.selectedContentType !== 'live') {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
}
);
}
}
}
toggleFavorite() {
this.xtreamStore.toggleFavorite();
}
private addToRecentlyViewed(vodItem: any) {
this.xtreamStore.addRecentItem({
contentId:
this.route.snapshot.params
.vodId /* vodItem.movie_data.stream_id */,
playlist: this.xtreamStore.currentPlaylist,
});
}
}
+131
View File
@@ -0,0 +1,131 @@
import { inject, Signal } from '@angular/core';
import {
patchState,
signalStoreFeature,
withMethods,
withState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { DatabaseService } from '../services/database.service';
export interface RecentlyViewedItem {
id: number;
title: string;
type: 'live' | 'movie' | 'series';
poster_url: string;
content_id: number;
playlist_id: string;
viewed_at: string;
xtream_id: number;
}
export const withRecentItems = function () {
return signalStoreFeature(
withState({
recentItems: [],
}),
withMethods((store, dbService = inject(DatabaseService)) => ({
loadRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
if (!playlist) return [];
console.log(
'Loading recent items for playlist',
playlist.id
);
const db = await dbService.getConnection();
return db.select<RecentlyViewedItem[]>(
`SELECT
rv.id,
c.title,
c.type,
c.poster_url,
c.id as content_id,
rv.playlist_id,
rv.viewed_at,
c.xtream_id,
c.category_id
FROM recently_viewed rv
JOIN content c ON rv.content_id = c.id
WHERE rv.playlist_id = ?
ORDER BY rv.viewed_at DESC
LIMIT 50`,
[playlist.id]
);
}),
tap((items: RecentlyViewedItem[]) =>
patchState(store, { recentItems: items })
)
)
),
})),
withMethods((store, dbService = inject(DatabaseService)) => ({
addRecentItem: rxMethod<{
contentId: number;
playlist: Signal<{ id: string }>;
}>(
pipe(
switchMap(async ({ contentId, playlist }) => {
if (!playlist().id) {
console.error('No active playlist found');
return;
}
console.log(
'Adding to recently viewed:',
playlist().id
);
const db = await dbService.getConnection();
const content: any = await db.select(
'SELECT id FROM content WHERE xtream_id = ?',
[contentId]
);
if (content && content.length > 0) {
// Check if item already exists in recently_viewed
const existing: any = await db.select(
'SELECT id FROM recently_viewed WHERE content_id = ? AND playlist_id = ?',
[content[0].id, playlist().id]
);
if (existing && existing.length > 0) {
// Update existing record's viewed_at timestamp
await db.execute(
'UPDATE recently_viewed SET viewed_at = CURRENT_TIMESTAMP WHERE id = ?',
[existing[0].id]
);
} else {
// Insert new record
await db.execute(
`INSERT INTO recently_viewed (content_id, playlist_id)
VALUES (?, ?)`,
[content[0].id, playlist().id]
);
}
return store.loadRecentItems({ id: playlist().id });
}
})
)
),
clearRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
console.log(
'Clearing recent items for playlist',
playlist.id
);
const db = await dbService.getConnection();
await db.execute(
`DELETE FROM recently_viewed WHERE playlist_id = ?`,
[playlist.id]
);
return store.loadRecentItems({ id: playlist.id });
})
)
),
}))
);
};
@@ -0,0 +1,46 @@
@if (xtreamStore.isImporting()) {
<app-loading-overlay
[current]="xtreamStore.getImportCount()"
[total]="xtreamStore.itemsToImport()"
/>
}
<div class="xtream-container">
<div class="sidebar navigation-sidebar">
<app-navigation />
</div>
@if (
xtreamStore.selectedContentType() === 'vod' ||
xtreamStore.selectedContentType() === 'series'
) {
<div class="sidebar">
<div class="sidebar-header">
<h2>Categories</h2>
</div>
<app-category-view (categoryClicked)="categoryClicked($event)" />
</div>
}
<div class="main-content">
@if (
xtreamStore.selectedContentType() === 'vod' ||
xtreamStore.selectedContentType() === 'series'
) {
<div class="content-header">
<h2>
@if (xtreamStore.selectedItem()) {
<button mat-icon-button (click)="historyBack()">
<mat-icon>chevron_left</mat-icon>
</button>
}
{{ getContentLabel() }}
</h2>
</div>
}
<div class="scrollable-content">
<router-outlet />
</div>
<app-mpv-player-bar />
</div>
</div>
@@ -0,0 +1,61 @@
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.xtream-container {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0; // Important for proper flex behavior
}
.sidebar {
width: 300px;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
.sidebar-header {
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 500;
}
}
}
.navigation-sidebar {
width: 200px;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; // Important for proper flex behavior
.content-header {
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
}
}
.scrollable-content {
flex: 1;
overflow-y: auto;
}
}
@@ -0,0 +1,98 @@
import { Component, effect, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { CategoryViewComponent } from './category-view/category-view.component';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamCategory } from '../../../shared/xtream-category.interface';
import { MpvPlayerBarComponent } from '../shared/components/mpv-player-bar/mpv-player-bar.component';
import * as PlaylistActions from '../state/actions';
import { LoadingOverlayComponent } from './loading-overlay/loading-overlay.component';
import { NavigationComponent } from './navigation/navigation.component';
import { XtreamStore } from './xtream.store';
@Component({
selector: 'app-xtream-main-container',
templateUrl: './xtream-main-container.component.html',
styleUrls: ['./xtream-main-container.component.scss'],
standalone: true,
imports: [
CategoryViewComponent,
TranslateModule,
RouterOutlet,
NavigationComponent,
LoadingOverlayComponent,
MpvPlayerBarComponent,
MatIcon,
MatIconButton,
],
providers: [XtreamStore],
})
export class XtreamMainContainerComponent {
readonly xtreamStore = inject(XtreamStore);
private store = inject(Store);
constructor(
private router: Router,
private route: ActivatedRoute
) {
effect(
() => {
if (this.xtreamStore.currentPlaylist() !== null) {
console.log(
'Initializing content...',
this.xtreamStore.currentPlaylist()
);
this.xtreamStore.initializeContent();
}
},
{ allowSignalWrites: true }
);
}
ngOnInit() {
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: this.route.snapshot.params.id,
})
);
//this.xtreamStore.fetchXtreamPlaylist();
}
categoryClicked(category: XtreamCategory) {
const categoryId = (category as any).category_id ?? category.id;
console.log('Category clicked:', category);
this.xtreamStore.setSelectedCategory(Number(categoryId));
this.router.navigate([categoryId], {
relativeTo: this.route,
});
}
getContentLabel(): string {
if (
this.xtreamStore.getSelectedCategory() === null ||
this.xtreamStore.getSelectedCategory() === undefined
) {
return 'Select a category';
} else {
// TODO: Fix this
//console.log(this.xtreamStore.getSelectedCategory());
const selectedCategory = this.xtreamStore.getSelectedCategory();
return selectedCategory
? `Content for ${(selectedCategory as any).name}`
: 'Category Content';
}
}
historyBack() {
this.router.navigate([
'./xtreams',
this.xtreamStore.currentPlaylist().id,
this.xtreamStore.selectedCategoryId(),
]);
}
}
+70 -82
View File
@@ -3,15 +3,13 @@ import { ActivatedRoute } from '@angular/router';
import {
patchState,
signalStore,
watchState,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { Store } from '@ngrx/store';
import { from, lastValueFrom, pipe, switchMap, tap } from 'rxjs';
import { EMPTY, from, lastValueFrom, pipe, switchMap, tap } from 'rxjs';
import { XtreamCategory } from '../../../shared/xtream-category.interface';
import { XtreamCodeActions } from '../../../shared/xtream-code-actions';
import { XtreamLiveStream } from '../../../shared/xtream-live-stream.interface';
@@ -45,6 +43,7 @@ type XtreamState = {
currentPlaylist: any | null;
isFavorite: boolean;
epgItems: EpgItem[];
hideExternalInfoDialog: boolean;
};
const initialState: XtreamState = {
@@ -59,7 +58,7 @@ const initialState: XtreamState = {
vodStreams: [],
serialStreams: [],
page: 1,
limit: 20,
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
selectedCategoryId: null,
searchResults: [],
selectedContentType: 'vod',
@@ -68,6 +67,8 @@ const initialState: XtreamState = {
currentPlaylist: null,
isFavorite: false,
epgItems: [],
hideExternalInfoDialog:
localStorage.getItem('hideExternalInfoDialog') === 'true',
};
interface XCategoryFromDb {
@@ -312,7 +313,7 @@ export const XtreamStore = signalStore(
return [
categoryId,
title,
stream.rating || '',
stream.rating || stream.rating_imdb || '',
type === 'series'
? stream.last_modified || ''
: stream.added || '',
@@ -394,13 +395,16 @@ export const XtreamStore = signalStore(
type: 'live' | 'movie' | 'series'
) => {
const playlistId = route.snapshot.params.id;
console.log('Getting local content...');
const db = await dbService.getConnection();
return await db.select(
`SELECT c.* FROM content c
JOIN categories cat ON c.category_id = cat.id
WHERE cat.playlist_id = ? AND c.type = ?`,
`SELECT
c.id, c.category_id, c.title, c.rating,
c.added, c.poster_url, c.xtream_id, c.type
FROM content c
INNER JOIN categories cat ON c.category_id = cat.id
WHERE cat.playlist_id = ? AND c.type = ?
ORDER BY c.added DESC`,
[playlistId, type]
);
};
@@ -438,7 +442,7 @@ export const XtreamStore = signalStore(
store.currentPlaylist()
);
const remoteData = await dataService.fetchData(
`${store.currentPlaylist().api_url}/player_api.php`,
`${store.currentPlaylist().serverUrl}/player_api.php`,
queryParams
);
@@ -513,7 +517,7 @@ export const XtreamStore = signalStore(
}
const remoteData = await dataService.fetchData(
`${store.currentPlaylist().api_url}/player_api.php`,
`${store.currentPlaylist().serverUrl}/player_api.php`,
queryParams
);
@@ -557,30 +561,10 @@ export const XtreamStore = signalStore(
patchState(store, { searchResults: results });
};
/* const fetchXtreamPlaylist = rxMethod<void>(
pipe(() => {
const playlistId = route.snapshot.params.id;
return from(dbService.getConnection()).pipe(
switchMap((db) =>
from(
db.select(
'SELECT * FROM playlists WHERE id = ?',
[playlistId]
)
)
),
tap((res) => {
console.log('Fetched playlist:', res);
if (res[0])
patchState(store, { currentPlaylist: res[0] });
})
);
})
); */
const fetchXtreamPlaylist = rxMethod<void>(
pipe(() => {
const playlistId = route.snapshot.params.id;
if (!playlistId) return EMPTY;
return from(dbService.getConnection()).pipe(
switchMap((db) =>
from(
@@ -606,7 +590,7 @@ export const XtreamStore = signalStore(
const db = await dbService.getConnection();
// Insert into DB
await db.execute(
'INSERT INTO playlists (id, name, api_url, username, password, type) VALUES (?, ?, ?, ?, ?, ?)',
'INSERT INTO playlists (id, name, serverUrl, username, password, type) VALUES (?, ?, ?, ?, ?, ?)',
[
playlist._id,
playlist.title,
@@ -619,7 +603,7 @@ export const XtreamStore = signalStore(
patchState(store, {
currentPlaylist: {
...playlist,
api_url: playlist.serverUrl,
serverUrl: playlist.serverUrl,
id: playlist._id,
name: playlist.title,
},
@@ -734,7 +718,7 @@ export const XtreamStore = signalStore(
const currentPlaylist = store.currentPlaylist();
const password = currentPlaylist?.password;
const url = `${currentPlaylist?.api_url}/player_api.php`;
const url = `${currentPlaylist?.serverUrl}/player_api.php`;
const username = currentPlaylist?.username;
console.log('Fetching VOD details...', password, url, username);
@@ -757,7 +741,7 @@ export const XtreamStore = signalStore(
const currentPlaylist = store.currentPlaylist();
const password = currentPlaylist?.password;
const url = `${currentPlaylist?.api_url}/player_api.php`;
const url = `${currentPlaylist?.serverUrl}/player_api.php`;
const username = currentPlaylist?.username;
console.log(
'Fetching Series details...',
@@ -786,80 +770,51 @@ export const XtreamStore = signalStore(
try {
console.log('Starting content initialization...');
// Categories
console.log('Fetching live categories...');
const liveCategories: any = await lastValueFrom(
await lastValueFrom(
fetchCategories(
XtreamCodeActions.GetLiveCategories,
'liveCategories',
'live'
)
);
console.log(
'Live categories loaded:',
liveCategories?.length
);
console.log('Fetching VOD categories...');
const vodCategories: any = await lastValueFrom(
await lastValueFrom(
fetchCategories(
XtreamCodeActions.GetVodCategories,
'vodCategories',
'movies'
)
);
console.log(
'VOD categories loaded:',
vodCategories?.length
);
console.log('Fetching serial categories...');
const serialCategories: any = await lastValueFrom(
await lastValueFrom(
fetchCategories(
XtreamCodeActions.GetSeriesCategories,
'serialCategories',
'series'
)
);
console.log(
'Serial categories loaded:',
serialCategories?.length
);
// Streams
console.log('Fetching live streams...');
const liveStreams: any = await lastValueFrom(
console.log('Fetching content...');
await lastValueFrom(
fetchStreams(
XtreamCodeActions.GetLiveStreams,
'liveStreams',
'live'
)
);
console.log('Live streams loaded:', liveStreams?.length);
console.log('Fetching VOD streams...');
const vodStreams: any = await lastValueFrom(
await lastValueFrom(
fetchStreams(
XtreamCodeActions.GetVodStreams,
'vodStreams',
'movie'
)
);
console.log('VOD streams loaded:', vodStreams?.length);
console.log('Fetching serial streams...');
const serialStreams: any = await lastValueFrom(
await lastValueFrom(
fetchStreams(
XtreamCodeActions.GetSeries,
'serialStreams',
'series'
)
);
console.log(
'Serial streams loaded:',
serialStreams?.length
);
console.log('Content initialization completed');
} catch (error) {
console.error(
@@ -888,13 +843,21 @@ export const XtreamStore = signalStore(
getVodDetails,
getSerialDetails,
initializeContent,
updatePlaylist(playlist: any) {
patchState(store, {
currentPlaylist: {
...store.currentPlaylist(),
playlist,
},
});
},
resetSearchResults() {
patchState(store, { searchResults: [] });
},
async loadEpg() {
console.log('Loading EPG...');
const remoteData = await dataService.fetchData(
`${store.currentPlaylist().api_url}/player_api.php`,
`${store.currentPlaylist().serverUrl}/player_api.php`,
{
action: 'get_short_epg',
username: store.currentPlaylist().username,
@@ -934,8 +897,18 @@ export const XtreamStore = signalStore(
if (!item || !playlist) return;
const contentId =
item.movie_data?.stream_id || item.series_id;
const db = await dbService.getConnection();
const content: any = await db.select(
'SELECT id FROM content WHERE xtream_id = ?',
[item.movie_data?.stream_id || item.series_id]
);
if (!content || content.length === 0) {
console.error('Content not found in database');
return;
}
const contentId = content[0].id;
const isFavorite = await favoritesService.isFavorite(
contentId,
playlist.id
@@ -950,9 +923,6 @@ export const XtreamStore = signalStore(
await favoritesService.addToFavorites({
content_id: contentId,
playlist_id: playlist.id,
type: store.selectedContentType(),
title: item.name || item.title,
stream_icon: item.stream_icon || item.cover,
});
}
@@ -968,21 +938,39 @@ export const XtreamStore = signalStore(
return;
}
const db = await dbService.getConnection();
const content: any = await db.select(
'SELECT id FROM content WHERE xtream_id = ?',
[item.xtream_id]
);
if (!content || content.length === 0) {
patchState(store, { isFavorite: false });
return;
}
const isFavorite = await favoritesService.isFavorite(
item.xtream_id || item.xtream_id,
content[0].id,
playlist.id
);
patchState(store, { isFavorite });
},
setHideExternalInfoDialog(hideExternalInfoDialog: boolean) {
localStorage.setItem(
'hideExternalInfoDialog',
store.hideExternalInfoDialog.toString()
);
patchState(store, { hideExternalInfoDialog });
},
};
}
),
withHooks((store) => ({
)
/* withHooks((store) => ({
onInit() {
watchState(store, (state) => {
console.log('[watchState] xtream state', state);
});
},
}))
})) */
);