mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(tauri): new xtream module
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
@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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
}))
|
||||
})) */
|
||||
);
|
||||
Reference in new issue
Block a user