Merge pull request #613 from 4gray/chore/update-angular-to-v20

chore: update to angular v20
This commit is contained in:
4gray authored and GitHub committed 2025-09-21 13:09:18 +02:00
commit e56ebce3a3
47 files changed
+31451 -29290

No files matched your search

+22 -2
View File
@@ -137,10 +137,30 @@
"schematics": {
"@schematics/angular:component": {
"prefix": "app",
"style": "scss"
"style": "scss",
"type": "component"
},
"@schematics/angular:directive": {
"prefix": "app"
"prefix": "app",
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
+29850 -27749
View File
File diff suppressed because it is too large. Load diff
+148 -149
View File
@@ -1,151 +1,150 @@
{
"name": "iptvnator",
"version": "1.0.0-beta.7",
"description": "IPTV player application.",
"homepage": "https://github.com/4gray/iptvnator",
"author": {
"name": "4gray",
"email": "fourgray@proton.me"
},
"keywords": [
"angular",
"electron",
"typescript",
"m3u",
"m3u8",
"player",
"iptv",
"video",
"tv"
],
"main": "electron/main.js",
"private": true,
"scripts": {
"build:dev": "ng build -c dev",
"build:prod": "ng build -c production",
"build:web": "ng build -c web",
"serve": "ng serve -c dev",
"test": "ng test",
"test:watch": "ng test --watch",
"e2e": "npm run build:prod && npx playwright test --ui",
"e2e:ci": "npx playwright test --retries 3 --workers=1",
"version": "conventional-changelog -i CHANGELOG.md -p angular -s -r 0 && git add CHANGELOG.md",
"lint": "ng lint",
"build-pwa": "ng build -c production --base-href /iptvnator --deploy-url /iptvnator/",
"tauri": "tauri"
},
"dependencies": {
"@angular-builders/jest": "17.0.0",
"@angular/animations": "19.2.0",
"@angular/cdk": "19.2.1",
"@angular/material": "19.2.1",
"@ngrx/component-store": "19.0.1",
"@ngrx/effects": "19.0.1",
"@ngrx/entity": "19.0.1",
"@ngrx/router-store": "19.0.1",
"@ngrx/signals": "19.0.1",
"@ngrx/store": "19.0.1",
"@ngrx/store-devtools": "19.0.1",
"@ngx-pwa/local-storage": "18.0.0",
"@rx-angular/cdk": "18.0.0",
"@rx-angular/template": "18.0.0",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/plugin-dialog": "2.2.0",
"@tauri-apps/plugin-fs": "2.2.0",
"@tauri-apps/plugin-http": "2.3.0",
"@tauri-apps/plugin-os": "2.2.0",
"@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-sql": "2.2.0",
"@tauri-apps/plugin-updater": "2.5.1",
"@tauri-apps/plugin-window-state": "2.2.1",
"@videojs/http-streaming": "2.15.0",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"artplayer": "5.2.1",
"axios": "1.12.0",
"date-fns": "2.29.3",
"dplayer": "1.27.1",
"epg-parser": "0.1.6",
"hls.js": "1.6.1",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
"lodash": "4.17.21",
"moment": "2.30.1",
"ngx-indexed-db": "19.0.0",
"ngx-skeleton-loader": "10.0.0",
"ngx-whats-new": "0.4.0",
"node-mpv": "github:4gray/Node-MPV",
"rxjs": "7.8.1",
"semver": "7.5.2",
"squirrelly": "9.1.0",
"uuid": "9.0.0",
"video.js": "7.20.3",
"videojs-contrib-quality-levels": "2.2.0",
"videojs-hls-quality-selector": "1.1.4",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-eslint/builder": "19.2.0",
"@angular-eslint/eslint-plugin": "19.2.0",
"@angular-eslint/eslint-plugin-template": "19.2.0",
"@angular-eslint/schematics": "19.2.0",
"@angular-eslint/template-parser": "19.2.0",
"@angular/build": "^19.2.17",
"@angular/cli": "19.2.0",
"@angular/common": "19.2.0",
"@angular/compiler": "19.2.0",
"@angular/compiler-cli": "19.2.0",
"@angular/core": "19.2.0",
"@angular/forms": "19.2.0",
"@angular/language-service": "19.2.0",
"@angular/platform-browser": "19.2.0",
"@angular/platform-browser-dynamic": "19.2.0",
"@angular/router": "19.2.0",
"@angular/service-worker": "19.2.0",
"@commitlint/cli": "18.4.3",
"@commitlint/config-conventional": "18.4.3",
"@eslint/eslintrc": "3.1.0",
"@eslint/js": "9.9.1",
"@ngrx/eslint-plugin": "^19.0.1",
"@ngx-translate/core": "16.0.4",
"@ngx-translate/http-loader": "16.0.1",
"@playwright/test": "1.46.1",
"@semantic-release/changelog": "6.0.3",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "12.0.1",
"@tauri-apps/cli": "2.3.1",
"@types/dplayer": "1.25.5",
"@types/jest": "29.5.14",
"@types/mocha": "9.0.0",
"@types/node": "20.10.0",
"@types/video.js": "7.3.29",
"@types/videojs-hls-quality-selector": "1.1.0",
"@typescript-eslint/eslint-plugin": "^8.2.0",
"@typescript-eslint/eslint-plugin-tslint": "7.0.2",
"@typescript-eslint/parser": "^8.2.0",
"conventional-changelog-cli": "5.0.0",
"eslint": "^9.9.1",
"eslint-plugin-import": "2.29.1",
"globals": "15.9.0",
"husky": "5.0.9",
"jest": "29.7.0",
"jest-preset-angular": "14.5.3",
"ng-mocks": "14.13.2",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.46.1",
"playwright-core": "1.46.1",
"prettier": "3.3.3",
"semantic-release": "19.0.3",
"ts-jest": "29.2.6",
"ts-node": "10.9.2",
"tslib": "2.3.0",
"typescript": "5.5.4",
"wait-on": "6.0.0"
},
"engines": {
"node": ">=20.0.0"
},
"browserslist": [
"chrome 91"
]
"name": "iptvnator",
"version": "1.0.0-beta.7",
"description": "IPTV player application.",
"homepage": "https://github.com/4gray/iptvnator",
"author": {
"name": "4gray",
"email": "fourgray@proton.me"
},
"keywords": [
"angular",
"electron",
"typescript",
"m3u",
"m3u8",
"player",
"iptv",
"video",
"tv"
],
"main": "electron/main.js",
"private": true,
"scripts": {
"build:dev": "ng build -c dev",
"build:prod": "ng build -c production",
"build:web": "ng build -c web",
"serve": "ng serve -c dev",
"test": "ng test",
"test:watch": "ng test --watch",
"e2e": "npm run build:prod && npx playwright test --ui",
"e2e:ci": "npx playwright test --retries 3 --workers=1",
"version": "conventional-changelog -i CHANGELOG.md -p angular -s -r 0 && git add CHANGELOG.md",
"lint": "ng lint",
"build-pwa": "ng build -c production --base-href /iptvnator --deploy-url /iptvnator/",
"tauri": "tauri"
},
"dependencies": {
"@angular-builders/jest": "17.0.0",
"@angular/animations": "20.3.1",
"@angular/cdk": "20.2.4",
"@angular/material": "20.2.4",
"@ngrx/component-store": "20.0.0",
"@ngrx/effects": "20.0.0",
"@ngrx/entity": "20.0.0",
"@ngrx/router-store": "20.0.0",
"@ngrx/signals": "20.0.0",
"@ngrx/store": "20.0.0",
"@ngrx/store-devtools": "20.0.0",
"@ngx-pwa/local-storage": "20.0.0",
"@rx-angular/cdk": "18.0.0",
"@rx-angular/template": "18.0.0",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/plugin-dialog": "2.2.0",
"@tauri-apps/plugin-fs": "2.2.0",
"@tauri-apps/plugin-http": "2.3.0",
"@tauri-apps/plugin-os": "2.2.0",
"@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-sql": "2.2.0",
"@tauri-apps/plugin-updater": "2.5.1",
"@tauri-apps/plugin-window-state": "2.2.1",
"@videojs/http-streaming": "2.15.0",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"artplayer": "5.2.1",
"axios": "1.12.0",
"date-fns": "2.29.3",
"dplayer": "1.27.1",
"epg-parser": "0.1.6",
"hls.js": "1.6.1",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
"lodash": "4.17.21",
"moment": "2.30.1",
"ngx-indexed-db": "19.0.0",
"ngx-skeleton-loader": "10.0.0",
"ngx-whats-new": "0.4.0",
"node-mpv": "github:4gray/Node-MPV",
"rxjs": "7.8.1",
"semver": "7.5.2",
"uuid": "9.0.0",
"video.js": "7.20.3",
"videojs-contrib-quality-levels": "2.2.0",
"videojs-hls-quality-selector": "1.1.4",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-eslint/builder": "19.2.0",
"@angular-eslint/eslint-plugin": "19.2.0",
"@angular-eslint/eslint-plugin-template": "19.2.0",
"@angular-eslint/schematics": "19.2.0",
"@angular-eslint/template-parser": "19.2.0",
"@angular/build": "^20.3.2",
"@angular/cli": "20.3.2",
"@angular/common": "20.3.1",
"@angular/compiler": "20.3.1",
"@angular/compiler-cli": "20.3.1",
"@angular/core": "20.3.1",
"@angular/forms": "20.3.1",
"@angular/language-service": "20.3.1",
"@angular/platform-browser": "20.3.1",
"@angular/platform-browser-dynamic": "20.3.1",
"@angular/router": "20.3.1",
"@angular/service-worker": "20.3.1",
"@commitlint/cli": "18.4.3",
"@commitlint/config-conventional": "18.4.3",
"@eslint/eslintrc": "3.1.0",
"@eslint/js": "9.9.1",
"@ngrx/eslint-plugin": "^20.0.0",
"@ngx-translate/core": "16.0.4",
"@ngx-translate/http-loader": "16.0.1",
"@playwright/test": "1.46.1",
"@semantic-release/changelog": "6.0.3",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "12.0.1",
"@tauri-apps/cli": "2.3.1",
"@types/dplayer": "1.25.5",
"@types/jest": "29.5.14",
"@types/mocha": "9.0.0",
"@types/node": "20.10.0",
"@types/video.js": "7.3.29",
"@types/videojs-hls-quality-selector": "1.1.0",
"@typescript-eslint/eslint-plugin": "^8.2.0",
"@typescript-eslint/eslint-plugin-tslint": "7.0.2",
"@typescript-eslint/parser": "^8.2.0",
"conventional-changelog-cli": "5.0.0",
"eslint": "^9.9.1",
"eslint-plugin-import": "2.29.1",
"globals": "15.9.0",
"husky": "5.0.9",
"jest": "29.7.0",
"jest-preset-angular": "14.5.3",
"ng-mocks": "14.13.2",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.46.1",
"playwright-core": "1.46.1",
"prettier": "3.3.3",
"semantic-release": "19.0.3",
"ts-jest": "29.2.6",
"ts-node": "10.9.2",
"tslib": "^2.6.2",
"typescript": "5.9.2",
"wait-on": "6.0.0"
},
"engines": {
"node": ">=20.0.0"
},
"browserslist": [
"chrome 107"
]
}
+6
View File
@@ -257,6 +257,12 @@ pub async fn open_in_vlc<R: Runtime>(
}
}
if let Some(origin_url) = origin {
if !origin_url.is_empty() {
command.arg(format!("--http-origin={}", origin_url));
}
}
// Log the complete command line
let command_str = format!(
"{} {}",
+4 -3
View File
@@ -1,8 +1,9 @@
<router-outlet></router-outlet>
<!-- whats new dialog -->
<ngx-whats-new
*ngIf="isDialogVisible$ | async"
@if (isDialogVisible$ | async) {
<ngx-whats-new
(closeModal)="setDialogVisibility(false)"
[items]="modals"
[options]="options"
/>
/>
}
+1 -2
View File
@@ -8,7 +8,6 @@ import { isTauri } from '@tauri-apps/api/core';
import { ask } from '@tauri-apps/plugin-dialog';
import { relaunch } from '@tauri-apps/plugin-process';
import { check } from '@tauri-apps/plugin-updater';
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
import { firstValueFrom } from 'rxjs';
import * as semver from 'semver';
import { IpcCommand } from '../../shared/ipc-command.class';
@@ -47,7 +46,7 @@ export class AppComponent {
options = this.whatsNewService.options;
/** Modals to show for the updated version of the application */
modals: ModalWindow[] = [];
modals: any[] = [];
/** List of ipc commands with function mapping */
commandsList = [
+7 -5
View File
@@ -1,8 +1,10 @@
<div class="grid-container">
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
/>
<app-recent-playlists class="recent-playlists" />
<mat-progress-bar *ngIf="isLoading" mode="indeterminate" />
<app-recent-playlists class="recent-playlists" />
@if (isLoading) {
<mat-progress-bar mode="indeterminate" />
}
</div>
+6 -7
View File
@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import { Component, NgZone } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -16,12 +16,11 @@ import { RecentPlaylistsComponent } from './recent-playlists/recent-playlists.co
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
imports: [
HeaderComponent,
MatProgressBarModule,
NgIf,
RecentPlaylistsComponent,
TranslatePipe,
],
HeaderComponent,
MatProgressBarModule,
RecentPlaylistsComponent,
TranslatePipe
],
})
export class HomeComponent {
/** Loading spinner state */
@@ -9,75 +9,69 @@
autocomplete="off"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
(input)="onSearchQueryUpdate(searchQuery.value)"
/></mat-form-field>
/>
</mat-form-field>
@defer {
<mat-nav-list
*ngIf="playlists$ | async as playlists"
class="playlists-list"
>
<mat-list-item
*ngIf="playlists.length === 0; else playlistsTemplate"
>
<mat-icon matListItemIcon
>search</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
</div>
<div matListItemLine class="meta">
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
<ng-template #playlistsTemplate>
<mat-list-item (click)="getGlobalFavorites()">
<mat-icon class="favorites-icon" matListItemIcon
>star</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div matListItemLine class="meta">
{{
'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION'
| translate
}}
</div>
</mat-list-item>
<app-playlist-item
*ngFor="
let item of playlists;
last as last;
trackBy: trackByFn
"
[item]="item"
[showActions]="!sidebarMode"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
/>
</ng-template>
</mat-nav-list>
@if (playlists$ | async; as playlists) {
<mat-nav-list class="playlists-list">
@if (playlists.length === 0) {
<mat-list-item>
<mat-icon matListItemIcon>search</mat-icon>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
</div>
<div matListItemLine class="meta">
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
} @else {
<mat-list-item (click)="getGlobalFavorites()">
<mat-icon class="favorites-icon" matListItemIcon
>star</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div matListItemLine class="meta">
{{
'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION'
| translate
}}
</div>
</mat-list-item>
@for (item of playlists; track item._id; let last = $last) {
<app-playlist-item
[item]="item"
[showActions]="!sidebarMode"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
/>
}
}
</mat-nav-list>
}
} @placeholder {
<div id="skeleton-container">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader count="1" appearance="circle" />
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
/>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
/>
@for (a of ghostElements; track $index) {
<div class="skeleton-item">
<ngx-skeleton-loader count="1" appearance="circle" />
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
/>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
/>
</div>
<div class="actions">
<ngx-skeleton-loader count="2" appearance="circle" />
</div>
</div>
<div class="actions">
<ngx-skeleton-loader count="2" appearance="circle" />
</div>
</div>
}
</div>
}
}
@@ -1,5 +1,5 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { AsyncPipe } from '@angular/common';
import {
Component,
ElementRef,
@@ -56,8 +56,6 @@ import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
MatIconModule,
MatInputModule,
MatListModule,
NgFor,
NgIf,
NgxSkeletonLoaderComponent,
PlaylistItemComponent,
TranslatePipe,
@@ -66,7 +64,9 @@ import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
export class RecentPlaylistsComponent implements OnDestroy {
@ViewChild('searchQuery') searchQueryInput!: ElementRef<HTMLInputElement>;
searchQuery = new BehaviorSubject('');
readonly searchQuery = new BehaviorSubject('');
readonly ghostElements = new Array(10);
playlists$ = combineLatest([
this.store.select(selectAllPlaylistsMeta),
@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
@@ -14,7 +14,7 @@ import { Channel } from '../../../../../shared/channel.interface';
@Component({
selector: 'app-art-player',
imports: [CommonModule],
imports: [],
template: `<div #artplayer class="artplayer-container"></div>`,
styles: [
`
@@ -109,3 +109,15 @@
transform: scale(2);
margin: 0 20px;
}
@media only screen and (max-width: 599px) {
.radio-logo {
height: 200px;
width: 200px;
img {
max-width: 200px;
max-height: 200px;
}
}
}
@@ -1,4 +1,3 @@
import { NgIf } from '@angular/common';
import {
Component,
ElementRef,
@@ -29,59 +28,59 @@ import { setAdjacentChannelAsActive } from '../../../state/actions';
<mat-icon>skip_previous</mat-icon>
</button>
<button
class="icon-button-large"
mat-icon-button
(click)="play()"
*ngIf="playState === 'paused'"
>
<mat-icon>play_arrow</mat-icon>
</button>
<button
class="icon-button-large"
mat-icon-button
(click)="stop()"
*ngIf="playState === 'play'"
>
<mat-icon>pause</mat-icon>
</button>
@if (playState === 'paused') {
<button
class="icon-button-large"
mat-icon-button
(click)="play()"
>
<mat-icon>play_arrow</mat-icon>
</button>
}
@if (playState === 'play') {
<button
class="icon-button-large"
mat-icon-button
(click)="stop()"
>
<mat-icon>pause</mat-icon>
</button>
}
<button mat-icon-button (click)="switchChannel('next')">
<mat-icon>skip_next</mat-icon>
</button>
</div>
<div class="volume-panel">
<div class="playing" *ngIf="playState === 'play'">
<span class="playing-bar playing-bar1"></span>
<span class="playing-bar playing-bar2"></span>
<span class="playing-bar playing-bar3"></span>
</div>
<div class="playing" *ngIf="playState === 'paused'">
<span class="playing-bar-stopped playing-bar1"></span>
<span class="playing-bar-stopped playing-bar2"></span>
<span class="playing-bar-stopped playing-bar3"></span>
</div>
@if (playState === 'play') {
<div class="playing">
<span class="playing-bar playing-bar1"></span>
<span class="playing-bar playing-bar2"></span>
<span class="playing-bar playing-bar3"></span>
</div>
}
@if (playState === 'paused') {
<div class="playing">
<span class="playing-bar-stopped playing-bar1"></span>
<span class="playing-bar-stopped playing-bar2"></span>
<span class="playing-bar-stopped playing-bar3"></span>
</div>
}
<mat-slider min="0" max="1" step="0.1" color="accent">
<input matSliderThumb [(ngModel)]="audio.volume" />
</mat-slider>
<button mat-icon-button (click)="mute()">
<mat-icon *ngIf="audio.volume > 0 && !audio.muted"
>volume_up</mat-icon
>
<mat-icon *ngIf="audio.volume === 0 || audio.muted"
>volume_off</mat-icon
>
@if (audio.volume > 0 && !audio.muted) {
<mat-icon>volume_up</mat-icon>
}
@if (audio.volume === 0 || audio.muted) {
<mat-icon>volume_off</mat-icon>
}
</button>
</div>
</div>
`,
styleUrls: ['./audio-player.component.scss'],
imports: [
MatSliderModule,
MatIconModule,
MatButtonModule,
NgIf,
FormsModule,
],
imports: [MatSliderModule, MatIconModule, MatButtonModule, FormsModule],
})
export class AudioPlayerComponent implements OnChanges {
@Input() icon: string;
@@ -125,7 +124,6 @@ export class AudioPlayerComponent implements OnChanges {
}
switchChannel(direction: 'next' | 'previous') {
console.log(direction);
this.store.dispatch(setAdjacentChannelAsActive({ direction }));
}
}
@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
@@ -13,7 +13,7 @@ import { Channel } from '../../../../../shared/channel.interface';
@Component({
selector: 'app-d-player',
imports: [CommonModule],
imports: [],
template: `<div #dplayer class="dplayer-container"></div>`,
styles: [
`
@@ -1,40 +1,42 @@
<h2 mat-dialog-title>
{{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }}
{{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<ng-container *ngIf="epgProgram?.title?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
</div>
<p data-test="title">{{ epgProgram.title }}</p>
<div class="subheading-2" *ngIf="epgProgram.title[0].lang">
{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
</div>
<p data-test="lang">{{ epgProgram.title[0].lang }}</p>
</ng-container>
<ng-container *ngIf="epgProgram?.category?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
</div>
<p data-test="category">{{ epgProgram.category }}</p>
</ng-container>
<ng-container *ngIf="epgProgram?.desc?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
</div>
<p data-test="desc">{{ epgProgram.desc }}</p>
</ng-container>
<ng-container *ngIf="epgProgram?.rating?.length > 0">
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}
</div>
<p data-test="rating">
{{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}]
</p>
</ng-container>
@if (epgProgram?.title?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
</div>
<p data-test="title">{{ epgProgram.title }}</p>
@if (epgProgram.title[0].lang) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
</div>
}
<p data-test="lang">{{ epgProgram.title[0].lang }}</p>
}
@if (epgProgram?.category?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
</div>
<p data-test="category">{{ epgProgram.category }}</p>
}
@if (epgProgram?.desc?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
</div>
<p data-test="desc">{{ epgProgram.desc }}</p>
}
@if (epgProgram?.rating?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}
</div>
<p data-test="rating">
{{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}]
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
@@ -9,7 +9,7 @@ import { EpgProgram } from './../../../models/epg-program.model';
selector: 'app-epg-item-description',
templateUrl: './epg-item-description.component.html',
styleUrls: ['./epg-item-description.component.scss'],
imports: [MatButtonModule, MatDialogModule, NgIf, TranslatePipe],
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
})
export class EpgItemDescriptionComponent {
/** EPG program object */
@@ -1,136 +1,144 @@
<div id="epg-navigation">
<button
[matTooltip]="'CLOSE' | translate"
mat-button
(click)="close()"
color="accent"
<button
[matTooltip]="'CLOSE' | translate"
mat-button
(click)="close()"
color="accent"
>
<mat-icon>close</mat-icon> {{ 'CLOSE' | translate }}
<mat-icon>close</mat-icon> {{ 'CLOSE' | translate }}
</button>
<button
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
mat-icon-button
(click)="switchDay('prev')"
>
<mat-icon>navigate_before</mat-icon>
</button>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
mat-icon-button
(click)="switchDay('next')"
>
<mat-icon>navigate_next</mat-icon>
</button>
<div class="today-date">
{{ today | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
</div>
<button mat-icon-button (click)="zoomIn()" [disabled]="(hourWidth$ | async) >= 800">
<mat-icon>zoom_in</mat-icon>
</button>
<button mat-icon-button [disabled]="(hourWidth$ | async) <= 50" (click)="zoomOut()">
<mat-icon>zoom_out</mat-icon>
</button>
<div class="navigation-buttons">
<button
mat-button
(click)="previousChannels()"
[disabled]="channelsLowerRange === 0"
matTooltip="Previous channels"
>
<mat-icon>keyboard_arrow_up</mat-icon> Previous channels
</button>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
mat-icon-button
(click)="switchDay('prev')"
>
<mat-icon>navigate_before</mat-icon>
mat-button
(click)="nextChannels()"
[disabled]="isLastPage"
matTooltip="Next channels"
>
<mat-icon>keyboard_arrow_down</mat-icon> Next channels
</button>
<button
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
mat-icon-button
(click)="switchDay('next')"
>
<mat-icon>navigate_next</mat-icon>
</button>
<div class="today-date">
{{ today | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
</div>
<button mat-icon-button (click)="zoomIn()" [disabled]="(hourWidth$ | async) >= 800">
<mat-icon>zoom_in</mat-icon>
</button>
<button mat-icon-button [disabled]="(hourWidth$ | async) <= 50" (click)="zoomOut()">
<mat-icon>zoom_out</mat-icon>
</button>
<div class="navigation-buttons">
<button
mat-button
(click)="previousChannels()"
[disabled]="channelsLowerRange === 0"
matTooltip="Previous channels"
>
<mat-icon>keyboard_arrow_up</mat-icon> Previous channels
</button>
<button
mat-button
(click)="nextChannels()"
[disabled]="isLastPage"
matTooltip="Next channels"
>
<mat-icon>keyboard_arrow_down</mat-icon> Next channels
</button>
</div>
</div>
</div>
<div class="parent" #epgContainer>
<svg id="channels-column">
<g *ngFor="let item of channels$ | async; let i = index; trackBy: trackByIndex">
<!-- channel name -->
<g class="channel">
<rect
width="100"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
stroke="black"
/>
<foreignObject
width="100"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
>
<div class="channel-name">
<img
*ngIf="item.icon"
[src]="item.icon"
[alt]="item.name"
style="height: 20px; width: 20px; margin-right: 8px;"
>
<span>{{ item.name }}</span>
</div>
</foreignObject>
</g>
<svg id="channels-column">
@for (item of channels$ | async; track $any(item).channel; let i = $index) {
<g>
<!-- channel name -->
<g class="channel">
<rect
width="100"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
stroke="black"
/>
<foreignObject
width="100"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
>
<div class="channel-name">
@if (item.icon) {
<img
[src]="item.icon"
[alt]="item.name"
style="height: 20px; width: 20px; margin-right: 8px;"
>
}
<span>{{ item.name }}</span>
</div>
</foreignObject>
</g>
</g>
}
</svg>
<div id="epg-container">
<svg [attr.width]="(hourWidth$ | async) * 24" height="100%" id="epg-svg">
<!-- time headline -->
@for (hour of timeHeader; track hour) {
<g>
<rect
[matTooltip]="hour + ':00'"
[attr.width]="hourWidth$ | async"
[attr.height]="barHeight"
[attr.x]="hour * (hourWidth$ | async)"
fill="#000"
></rect>
<text
[attr.x]="hour * (hourWidth$ | async)"
y="10"
font-size="14"
fill="white"
transform="translate(20,20)"
>
{{ hour }}:00
</text>
</g>
}
<!-- epg channels with programs -->
@for (item of channels$ | async; track $any(item).channel; let i = $index) {
<g>
@for (program of item.programs; track trackByProgram($index, program)) {
<g
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
class="program"
(click)="showDescription(program)"
>
<rect
[attr.width]="program.width"
[attr.height]="barHeight"
[matTooltip]="program.title"
></rect>
<foreignObject
[attr.width]="program.width"
[attr.height]="barHeight"
>
<div class="program-title">{{ program.title }}</div>
</foreignObject>
</g>
}
</g>
}
<!-- current time line -->
<line
[attr.x1]="currentTimeLine"
y1="0"
[attr.x2]="currentTimeLine"
[attr.y2]="(channels$ | async)?.length * barHeight + barHeight"
stroke="red"
stroke-width="2"
></line>
</svg>
<div id="epg-container">
<svg [attr.width]="(hourWidth$ | async) * 24" height="100%" id="epg-svg">
<!-- time headline -->
<g *ngFor="let hour of timeHeader; trackBy: trackByIndex">
<rect
[matTooltip]="hour + ':00'"
[attr.width]="hourWidth$ | async"
[attr.height]="barHeight"
[attr.x]="hour * (hourWidth$ | async)"
fill="#000"
></rect>
<text
[attr.x]="hour * (hourWidth$ | async)"
y="10"
font-size="14"
fill="white"
transform="translate(20,20)"
>
{{ hour }}:00
</text>
</g>
<!-- epg channels with programs -->
<g *ngFor="let item of channels$ | async; let i = index; trackBy: trackByIndex">
<g
*ngFor="let program of item.programs; trackBy: trackByProgram"
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
class="program"
(click)="showDescription(program)"
>
<rect
[attr.width]="program.width"
[attr.height]="barHeight"
[matTooltip]="program.title"
></rect>
<foreignObject
[attr.width]="program.width"
[attr.height]="barHeight"
>
<div class="program-title">{{ program.title }}</div>
</foreignObject>
</g>
</g>
<!-- current time line -->
<line
[attr.x1]="currentTimeLine"
y1="0"
[attr.x2]="currentTimeLine"
[attr.y2]="(channels$ | async)?.length * barHeight + barHeight"
stroke="red"
stroke-width="2"
></line>
</svg>
</div>
</div>
</div>
@@ -134,9 +134,6 @@ export class MultiEpgContainerComponent
Math.min(calculatedVisibleChannels, 20)
);
this.channelsUpperRange = this.visibleChannels;
console.log('Container height:', containerHeight);
console.log('Calculated visible channels:', this.visibleChannels);
}
}
@@ -151,7 +148,7 @@ export class MultiEpgContainerComponent
}
trackByProgram(_: number, program: EnrichedProgram): string {
return program.start + program.title;
return `${program.start}|${program?.title?.toString() ?? ''}`;
}
async requestPrograms(): Promise<void> {
@@ -10,7 +10,7 @@
}
</mat-drawer>
<mat-drawer-content class="drawer-content">
<ng-container *ngIf="activeChannel$ | async as activeChannel">
@if (activeChannel$ | async; as activeChannel) {
<!-- toolbar with drawer icon -->
<app-toolbar
(toggleLeftDrawerClicked)="drawer.toggle()"
@@ -18,95 +18,94 @@
(multiEpgClicked)="openMultiEpgView()"
[activeChannel]="activeChannel"
/>
<app-audio-player
*ngIf="activeChannel.radio === 'true'; else videoPlayer"
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo"
/>
<ng-template #videoPlayer>
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo"
/>
} @else {
<!-- video.js player -->
<app-vjs-player
*ngIf="playerSettings.player === 'videojs'"
[options]="{
sources: [
{
src:
activeChannel?.url +
activeChannel?.epgParams,
type: 'application/x-mpegURL',
},
],
}"
[volume]="volume"
[class.hide-captions]="!playerSettings.showCaptions"
/>
@if (playerSettings.player === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src:
activeChannel?.url +
activeChannel?.epgParams,
type: 'application/x-mpegURL',
},
],
}"
[volume]="volume"
[class.hide-captions]="!playerSettings.showCaptions"
/>
}
<!-- default html player component -->
<app-html-video-player
*ngIf="playerSettings.player === 'html5'"
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions"
/>
@if (playerSettings.player === 'html5') {
<app-html-video-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions"
/>
}
<!-- Add DPlayer component -->
<app-d-player
*ngIf="playerSettings.player === 'dplayer'"
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions"
/>
@if (playerSettings.player === 'dplayer') {
<app-d-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions"
/>
}
<!-- Add ArtPlayer component -->
<app-art-player
*ngIf="playerSettings.player === 'artplayer'"
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions"
/>
<div
class="custom-player"
*ngIf="playerSettings.player === 'mpv'"
>
<img src="./assets/images/custom-player.png" />
<p>
The channel is played in a separate window in the MPV
player. (<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>Installation instructions</a
>)
</p>
</div>
<div
class="custom-player"
*ngIf="playerSettings.player === 'vlc'"
>
<img src="./assets/images/custom-player.png" />
<p>
The channel is played in a separate window in VLC
player. Please make sure that this player is installed
in your system and the correct path is defined in the
app settings.
</p>
</div>
@if (playerSettings.player === 'artplayer') {
<app-art-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions"
/>
}
@if (playerSettings.player === 'mpv') {
<div class="custom-player">
<img src="./assets/images/custom-player.png" />
<p>
The channel is played in a separate window in the
MPV player. (<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>Installation instructions</a
>)
</p>
</div>
}
@if (playerSettings.player === 'vlc') {
<div class="custom-player">
<img src="./assets/images/custom-player.png" />
<p>
The channel is played in a separate window in VLC
player. Please make sure that this player is
installed in your system and the correct path is
defined in the app settings.
</p>
</div>
}
<!-- channel overlay -->
<app-info-overlay
[channel]="activeChannel"
[epgProgram]="epgProgram$ | async"
/>
</ng-template>
</ng-container>
}
}
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer position="end" #drawerRight mode="side" disableClose>
<app-epg-list *ngIf="isTauri" />
@if (isTauri) {
<app-epg-list />
}
</mat-drawer>
</mat-drawer-container>
@@ -24,20 +24,20 @@
@media only screen and (max-width: 599px) {
.main-container {
display: flex;
flex-direction: column-reverse;
flex-direction: column-reverse;
top: unset;
}
.mat-drawer {
height: 50vh;
width: 100%;
height: 50vh;
width: 100%;
position: unset;
}
.drawer-content {
height: 50vh;
margin-left: 0 !important;
margin-left: 0 !important;
}
}
@media (prefers-color-scheme: dark) {
@@ -1,9 +1,11 @@
@if (player === 'videojs') {
<app-vjs-player [options]="vjsOptions" />
<app-vjs-player [options]="vjsOptions" />
} @else if (player === 'html5') {
<app-html-video-player *ngIf="player === 'html5'" [channel]="channel" />
@if (player === 'html5') {
<app-html-video-player [channel]="channel" />
}
} @else if (player === 'dplayer') {
<app-d-player [channel]="channel" [volume]="1" [showCaptions]="false" />
<app-d-player [channel]="channel" [volume]="1" [showCaptions]="false" />
} @else if (player === 'artplayer') {
<app-art-player [channel]="channel" [volume]="1" [showCaptions]="false" />
<app-art-player [channel]="channel" [volume]="1" [showCaptions]="false" />
}
@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import {
Component,
Signal,
@@ -22,12 +22,11 @@ import { STORE_KEY } from '../../shared/enums/store-keys.enum';
templateUrl: './web-player-view.component.html',
styleUrls: ['./web-player-view.component.scss'],
imports: [
HtmlVideoPlayerComponent,
NgIf,
VjsPlayerComponent,
DPlayerComponent,
ArtPlayerComponent,
],
HtmlVideoPlayerComponent,
VjsPlayerComponent,
DPlayerComponent,
ArtPlayerComponent
],
encapsulation: ViewEncapsulation.None
})
export class WebPlayerViewComponent {
+3 -3
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface';
/* import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface'; */
import { BehaviorSubject } from 'rxjs';
@Injectable({
@@ -300,14 +300,14 @@ export class WhatsNewService {
* Returns an array with modals for the provided version of the application
* @param version version string
*/
getModalsByVersion(version: string): ModalWindow[] {
getModalsByVersion(version: string) {
return this.modals[version] || [];
}
/**
* Returns modals with latest changes
*/
getLatestChanges(): ModalWindow[] {
getLatestChanges() {
const modalsLength = Object.keys(this.modals).length;
const lastVersion = Object.keys(this.modals)[modalsLength - 1];
return this.modals[lastVersion];
+330 -323
View File
@@ -1,6 +1,6 @@
@if (!isDialog) {
<app-header
[title]="'SETTINGS.TITLE' | translate"
<app-header
[title]="'SETTINGS.TITLE' | translate"
[subtitle]="
('SETTINGS.SUBTITLE' | translate) +
'. ' +
@@ -8,371 +8,378 @@
"
/>
} @else {
<h2 mat-dialog-title>{{ 'SETTINGS.TITLE' | translate }}</h2>
<h2 mat-dialog-title>{{ 'SETTINGS.TITLE' | translate }}</h2>
}
<div class="settings-container" data-test-id="settings-container">
<form [formGroup]="settingsForm" (ngSubmit)="onSubmit()" novalidate>
<ng-container formArrayName="epgUrl" *ngIf="isTauri">
<div class="row">
<div class="column">
{{ 'SETTINGS.EPG_URL_LABEL' | translate }}
<p>Add single or multiple URLs as EPG sources</p>
</div>
<div class="column">
<div
*ngFor="let _ of epgUrl.controls; index as i"
style="display: flex"
>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{
'SETTINGS.EPG_URL_PLACEHOLDER' | translate
}}</mat-label>
<input
matInput
type="url"
[formControlName]="i"
#epgField
/>
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
</mat-form-field>
<button
matLine
mat-icon-button
color="accent"
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
type="button"
[disabled]="epgField.value === ''"
(click)="refreshEpg(epgUrl.value[i])"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
matLine
color="accent"
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
type="button"
(click)="removeEpgSource(i)"
>
<mat-icon>remove</mat-icon>
</button>
</div>
<button
mat-button
color="accent"
type="button"
(click)="addEpgSource()"
>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
</div>
</div>
<mat-divider />
</ng-container>
<form [formGroup]="settingsForm" (ngSubmit)="onSubmit()" novalidate>
@if (isTauri) {
<ng-container formArrayName="epgUrl">
<div class="row">
<div class="column">
{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="player"
data-test-id="select-video-player"
>
<mat-option
*ngFor="let player of players"
[value]="player.id"
[attr.data-test-id]="player.id"
>{{ player.label }}
</mat-option>
</mat-select>
<div class="column">
{{ 'SETTINGS.EPG_URL_LABEL' | translate }}
<p>Add single or multiple URLs as EPG sources</p>
</div>
<div class="column">
@for (_ of epgUrl.controls; track _; let i = $index) {
<div
style="display: flex"
>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{
'SETTINGS.EPG_URL_PLACEHOLDER' | translate
}}</mat-label>
<input
matInput
type="url"
[formControlName]="i"
#epgField
/>
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
</mat-form-field>
</div>
<button
matLine
mat-icon-button
color="accent"
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
type="button"
[disabled]="epgField.value === ''"
(click)="refreshEpg(epgUrl.value[i])"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
matLine
color="accent"
[matTooltip]="'SETTINGS.REFRESH_EPG' | translate"
type="button"
(click)="removeEpgSource(i)"
>
<mat-icon>remove</mat-icon>
</button>
</div>
}
<button
mat-button
color="accent"
type="button"
(click)="addEpgSource()"
>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
</div>
</div>
@if (settingsForm.value.player === 'mpv') {
<div class="row">
<div class="column">
{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}
<p>
{{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label for="mpvPlayerPath">{{
'SETTINGS.MPV_PLAYER_PATH' | translate
}}</mat-label>
<input
matInput
type="text"
id="mpvPlayerPath"
formControlName="mpvPlayerPath"
/>
</mat-form-field>
</div>
</div>
}
@if (settingsForm.value.player === 'vlc') {
<div class="row">
<div class="column">
{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}
<p>
{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label for="vlcPlayerPath">{{
'SETTINGS.VLC_PLAYER_PATH' | translate
}}</mat-label>
<input
matInput
type="text"
id="vlcPlayerPath"
formControlName="vlcPlayerPath"
/>
</mat-form-field>
</div>
</div>
}
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.STREAM_FORMAT' | translate }}
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="streamFormat"
data-test-id="select-stream-format"
>
<mat-option
*ngFor="let streamFormat of streamFormatEnum | keyvalue"
[value]="streamFormat.value"
[attr.data-test-id]="streamFormat.value"
>{{ streamFormat.value }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
</ng-container>
}
<div class="row">
<div class="column">
{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="player"
data-test-id="select-video-player"
>
@for (player of players; track player) {
<mat-option
[value]="player.id"
[attr.data-test-id]="player.id"
>{{ player.label }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (settingsForm.value.player === 'mpv') {
<div class="row">
<div class="column">
{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}
<p>
{{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label for="mpvPlayerPath">{{
'SETTINGS.MPV_PLAYER_PATH' | translate
}}</mat-label>
<input
matInput
type="text"
id="mpvPlayerPath"
formControlName="mpvPlayerPath"
/>
</mat-form-field>
</div>
</div>
}
@if (settingsForm.value.player === 'vlc') {
<div class="row">
<div class="column">
{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}
<p>
{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label for="vlcPlayerPath">{{
'SETTINGS.VLC_PLAYER_PATH' | translate
}}</mat-label>
<input
matInput
type="text"
id="vlcPlayerPath"
formControlName="vlcPlayerPath"
/>
</mat-form-field>
</div>
</div>
}
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.STREAM_FORMAT' | translate }}
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="streamFormat"
data-test-id="select-stream-format"
>
@for (streamFormat of streamFormatEnum | keyvalue; track streamFormat) {
<mat-option
[value]="streamFormat.value"
[attr.data-test-id]="streamFormat.value"
>{{ streamFormat.value }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.LANGUAGE' | translate }}
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="language"
data-test-id="select-language"
>
@for (language of languageEnum | keyvalue; track language) {
<mat-option
[value]="language.value"
[attr.data-test-id]="language.value"
>{{
'LANGUAGES.' + language.key | translate
}}</mat-option
>
}
</mat-select>
</mat-form-field>
</div>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.THEME' | translate }}
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="theme"
data-test-id="select-theme"
>
@for (theme of themeEnum | keyvalue; track theme) {
<mat-option
[value]="theme.value"
[attr.data-test-id]="theme.value"
>{{
'THEMES.' + theme.value | translate
}}</mat-option
>
}
</mat-select>
</mat-form-field>
</div>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.LANGUAGE' | translate }}
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="language"
data-test-id="select-language"
>
<mat-option
*ngFor="let language of languageEnum | keyvalue"
[value]="language.value"
[attr.data-test-id]="language.value"
>{{
'LANGUAGES.' + language.key | translate
}}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.THEME' | translate }}
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="theme"
data-test-id="select-theme"
>
<mat-option
*ngFor="let theme of themeEnum | keyvalue"
[value]="theme.value"
[attr.data-test-id]="theme.value"
>{{
'THEMES.' + theme.value | translate
}}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}
<p>{{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }}</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-checkbox
class="column"
formControlName="showCaptions"
></mat-checkbox>
</div>
<div class="column">
{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}
<p>{{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }}</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-checkbox
class="column"
formControlName="showCaptions"
></mat-checkbox>
</div>
</div>
<!-- not ported to tauri yet
@if (isPwa || isTauri) {
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
<p>
{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}
</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-checkbox
class="column"
formControlName="remoteControl"
></mat-checkbox>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
<p>
{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}
</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-checkbox
class="column"
formControlName="remoteControl"
></mat-checkbox>
</div>
</div>
}
-->
@if (settingsForm.value.remoteControl === true) {
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}
<p>
{{
'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-form-field appearance="outline" class="full-width">
<input
matInput
type="text"
id="remoteControlPort"
formControlName="remoteControlPort"
/>
</mat-form-field>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}
<p>
{{
'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-form-field appearance="outline" class="full-width">
<input
matInput
type="text"
id="remoteControlPort"
formControlName="remoteControlPort"
/>
</mat-form-field>
</div>
</div>
}
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.VERSION' | translate }}
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
{{ version }} <br />
{{ updateMessage }}
</div>
<div class="column">
{{ 'SETTINGS.VERSION' | translate }}
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
{{ version }} <br />
{{ updateMessage }}
</div>
</div>
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}
<p>
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<button
mat-button
<div class="column">
{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}
<p>
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<button
mat-button
(click)="
$event.preventDefault();
$event.stopPropagation();
importData()
"
>
{{ 'SETTINGS.IMPORT_DATA' | translate }}
</button>
<button
mat-button
>
{{ 'SETTINGS.IMPORT_DATA' | translate }}
</button>
<button
mat-button
(click)="
$event.preventDefault();
$event.stopPropagation();
exportData()
"
>
{{ 'SETTINGS.EXPORT_DATA' | translate }}
</button>
</div>
>
{{ 'SETTINGS.EXPORT_DATA' | translate }}
</button>
</div>
</div>
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOVE_ALL' | translate }}
<p>
{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<button
mat-button
<div class="column">
{{ 'SETTINGS.REMOVE_ALL' | translate }}
<p>
{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}
</p>
</div>
<div class="column">
<button
mat-button
(click)="
$event.preventDefault();
$event.stopPropagation();
removeAll()
"
color="warn"
>
{{ 'SETTINGS.REMOVE_ALL_BUTTON' | translate }}
</button>
</div>
color="warn"
>
{{ 'SETTINGS.REMOVE_ALL_BUTTON' | translate }}
</button>
</div>
</div>
@if (isPwa) {
<div class="row">
{{ 'SETTINGS.EPG_NOTE' | translate }}
&nbsp;<a href="https://github.com/4gray/iptvnator/releases">{{
'SETTINGS.EPG_NOTE_URL_TEXT' | translate
}}</a>
</div>
}
<mat-divider />
<div class="action-buttons" align="end">
<button
mat-button
color="accent"
type="reset"
(click)="backToHome()"
data-test-id="back-to-home"
>
<mat-icon>chevron_left</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
<button
mat-button
color="accent"
type="submit"
[disabled]="settingsForm.pristine || !settingsForm.valid"
data-test-id="save-settings"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
<div class="row">
{{ 'SETTINGS.EPG_NOTE' | translate }}
&nbsp;<a href="https://github.com/4gray/iptvnator/releases">{{
'SETTINGS.EPG_NOTE_URL_TEXT' | translate
}}</a>
</div>
}
<mat-divider />
<div class="action-buttons" align="end">
<button
mat-button
color="accent"
type="reset"
(click)="backToHome()"
data-test-id="back-to-home"
>
<mat-icon>chevron_left</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
<button
mat-button
color="accent"
type="submit"
[disabled]="settingsForm.pristine || !settingsForm.valid"
data-test-id="save-settings"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
</div>
</form>
</div>
</div>
@@ -6,253 +6,240 @@
<div class="title">{{ title }}</div>
<div class="subtitle">{{ subtitle }}</div>
</div>
<div class="menu" *ngIf="isHome">
<button
mat-icon-button
class="add-playlist-btn"
[matMenuTriggerFor]="addPlaylistMenu"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
data-test-id="add-playlist"
>
<mat-icon>add</mat-icon>
</button>
<!-- Sort button -->
<button
mat-icon-button
[matMenuTriggerFor]="sortMenu"
[matTooltip]="'HOME.SORT_PLAYLISTS' | translate"
data-test-id="sort-playlists"
>
<mat-icon>sort</mat-icon>
</button>
<button
mat-icon-button
[matMenuTriggerFor]="filterPlaylistMenu"
[matTooltip]="'HOME.FILTER_BY_TYPE' | translate"
data-test-id="filter-playlist-by-type"
>
<mat-icon>filter_alt</mat-icon>
</button>
<button
*ngIf="isElectron || isTauri; else pwaMenu"
mat-icon-button
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
data-test-id="open-settings"
>
<mat-icon>settings</mat-icon>
</button>
<!-- Sort menu -->
<mat-menu #sortMenu="matMenu">
<div
class="dropdown-menu-header"
(click)="$event.stopPropagation()"
>
{{ 'HOME.SORT_BY' | translate }}
</div>
@if (isHome) {
<div class="menu">
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.ASC)"
[class.active-sort]="isSortActive(SortBy.NAME, SortOrder.ASC)"
mat-icon-button
class="add-playlist-btn"
[matMenuTriggerFor]="addPlaylistMenu"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
data-test-id="add-playlist"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_ASC' | translate }}</span>
<mat-icon
*ngIf="isSortActive(SortBy.NAME, SortOrder.ASC)"
class="check-icon"
>check</mat-icon
>
<mat-icon>add</mat-icon>
</button>
<!-- Sort button -->
<button
mat-icon-button
[matMenuTriggerFor]="sortMenu"
[matTooltip]="'HOME.SORT_PLAYLISTS' | translate"
data-test-id="sort-playlists"
>
<mat-icon>sort</mat-icon>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.DESC)"
[class.active-sort]="isSortActive(SortBy.NAME, SortOrder.DESC)"
mat-icon-button
[matMenuTriggerFor]="filterPlaylistMenu"
[matTooltip]="'HOME.FILTER_BY_TYPE' | translate"
data-test-id="filter-playlist-by-type"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_DESC' | translate }}</span>
<mat-icon
*ngIf="isSortActive(SortBy.NAME, SortOrder.DESC)"
class="check-icon"
>check</mat-icon
>
<mat-icon>filter_alt</mat-icon>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NEWEST' | translate }}</span>
<mat-icon
*ngIf="isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)"
class="check-icon"
>check</mat-icon
@if (isElectron || isTauri) {
<button
mat-icon-button
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
data-test-id="open-settings"
>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.OLDEST' | translate }}</span>
<mat-icon
*ngIf="isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)"
class="check-icon"
>check</mat-icon
<mat-icon>settings</mat-icon>
</button>
} @else {
<button
mat-icon-button
[matMenuTriggerFor]="menu"
[attr.aria-label]="'MENU.OPEN' | translate"
data-test-id="pwa-menu"
>
</button>
</mat-menu>
<mat-menu #filterPlaylistMenu="matMenu">
<section
(click)="$event.stopPropagation()"
class="filter-container"
>
<mat-icon>menu</mat-icon>
</button>
<mat-menu #menu="matMenu">
@if (isHome) {
<button
mat-menu-item
[attr.aria-label]="'MENU.SETTINGS_ARIA' | translate"
(click)="openSettings()"
data-test-id="pwa-open-settings"
>
<mat-icon>settings</mat-icon>
<span>{{ 'MENU.SETTINGS' | translate }}</span>
</button>
<mat-divider></mat-divider>
}
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_ARIA' | translate"
(click)="openUrl('https:///github.com/4gray/iptvnator')"
>
<mat-icon>recommend</mat-icon>
<span>GitHub</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'MENU.BUG_REPORT' | translate"
(click)="
openUrl('https://github.com/4gray/iptvnator/issues')
"
>
<mat-icon>bug_report</mat-icon>
<span>{{ 'MENU.BUG_REPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'MENU.WHAT_IS_NEW' | translate"
(click)="setDialogVisibility(true)"
>
<mat-icon>new_releases</mat-icon>
<span>{{ 'MENU.WHAT_IS_NEW' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_PROJECT' | translate"
(click)="openUrl('https://github.com/sponsors/4gray')"
>
<mat-icon>savings</mat-icon>
<span>{{ 'MENU.SUPPORT_PROJECT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'MENU.ABOUT_ARIA' | translate"
(click)="openAboutDialog()"
>
<mat-icon>info</mat-icon>
<span>{{ 'MENU.ABOUT' | translate }}</span>
</button>
</mat-menu>
}
<!-- Sort menu -->
<mat-menu #sortMenu="matMenu">
<div
class="dropdown-menu-header"
(click)="$event.stopPropagation()"
>
{{ 'HOME.FILTER_BY_TYPE' | translate }}
{{ 'HOME.SORT_BY' | translate }}
</div>
@for (type of playlistTypes; track $index) {
<p>
<mat-checkbox
(click)="$event.stopPropagation()"
[checked]="true"
(change)="onPlaylistFilterChange()"
[(ngModel)]="type.checked"
>{{ type.title }}</mat-checkbox
>
</p>
}
</section>
</mat-menu>
<mat-menu #addPlaylistMenu="matMenu">
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.FILE_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('file')"
>
<mat-icon>attach_file</mat-icon>
<span>{{ 'HOME.TABS.FILE_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.URL_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('url')"
>
<mat-icon>link</mat-icon>
<span>{{ 'HOME.TABS.URL_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.TEXT_IMPORT' | translate"
(click)="opedAddPlaylistDialog('text')"
>
<mat-icon>text_format</mat-icon>
<span>{{ 'HOME.TABS.TEXT_IMPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.XTREME_IMPORT' | translate"
(click)="opedAddPlaylistDialog('xtream')"
>
<mat-icon>video_library</mat-icon>
<span>{{ 'HOME.TABS.XTREME_IMPORT' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="
'HOME.TABS.STALKER_PORTAL_IMPORT' | translate
"
(click)="opedAddPlaylistDialog('stalker')"
>
<mat-icon>dashboard</mat-icon>
<span>{{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }}</span>
</button>
</mat-menu>
</div>
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.ASC)"
[class.active-sort]="
isSortActive(SortBy.NAME, SortOrder.ASC)
"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_ASC' | translate }}</span>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.NAME, SortOrder.DESC)"
[class.active-sort]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NAME_DESC' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.NEWEST' | translate }}</span>
</button>
<button
mat-menu-item
(click)="setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC)"
[class.active-sort]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span>{{ 'HOME.SORT_OPTIONS.OLDEST' | translate }}</span>
</button>
</mat-menu>
<mat-menu #filterPlaylistMenu="matMenu">
<section
(click)="$event.stopPropagation()"
class="filter-container"
>
<div
class="dropdown-menu-header"
(click)="$event.stopPropagation()"
>
{{ 'HOME.FILTER_BY_TYPE' | translate }}
</div>
@for (type of playlistTypes; track $index) {
<p>
<mat-checkbox
(click)="$event.stopPropagation()"
[checked]="true"
(change)="onPlaylistFilterChange()"
[(ngModel)]="type.checked"
>{{ type.title }}</mat-checkbox
>
</p>
}
</section>
</mat-menu>
<mat-menu #addPlaylistMenu="matMenu">
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.FILE_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('file')"
>
<mat-icon>attach_file</mat-icon>
<span>{{ 'HOME.TABS.FILE_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.URL_UPLOAD' | translate"
(click)="opedAddPlaylistDialog('url')"
>
<mat-icon>link</mat-icon>
<span>{{ 'HOME.TABS.URL_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.TEXT_IMPORT' | translate"
(click)="opedAddPlaylistDialog('text')"
>
<mat-icon>text_format</mat-icon>
<span>{{ 'HOME.TABS.TEXT_IMPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.XTREME_IMPORT' | translate"
(click)="opedAddPlaylistDialog('xtream')"
>
<mat-icon>video_library</mat-icon>
<span>{{ 'HOME.TABS.XTREME_IMPORT' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="
'HOME.TABS.STALKER_PORTAL_IMPORT' | translate
"
(click)="opedAddPlaylistDialog('stalker')"
>
<mat-icon>dashboard</mat-icon>
<span>{{
'HOME.TABS.STALKER_PORTAL_IMPORT' | translate
}}</span>
</button>
</mat-menu>
</div>
}
</mat-toolbar>
<ng-template #pwaMenu>
<button
mat-icon-button
[matMenuTriggerFor]="menu"
[attr.aria-label]="'MENU.OPEN' | translate"
data-test-id="pwa-menu"
>
<mat-icon>menu</mat-icon>
</button>
<mat-menu #menu="matMenu">
<ng-container *ngIf="isHome">
<button
mat-menu-item
[attr.aria-label]="'MENU.SETTINGS_ARIA' | translate"
(click)="openSettings()"
data-test-id="pwa-open-settings"
>
<mat-icon>settings</mat-icon>
<span>{{ 'MENU.SETTINGS' | translate }}</span>
</button>
<mat-divider></mat-divider>
</ng-container>
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_ARIA' | translate"
(click)="openUrl('https:///github.com/4gray/iptvnator')"
>
<mat-icon>recommend</mat-icon>
<span>GitHub</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'MENU.BUG_REPORT' | translate"
(click)="openUrl('https://github.com/4gray/iptvnator/issues')"
>
<mat-icon>bug_report</mat-icon>
<span>{{ 'MENU.BUG_REPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'MENU.WHAT_IS_NEW' | translate"
(click)="setDialogVisibility(true)"
>
<mat-icon>new_releases</mat-icon>
<span>{{ 'MENU.WHAT_IS_NEW' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_PROJECT' | translate"
(click)="openUrl('https://github.com/sponsors/4gray')"
>
<mat-icon>savings</mat-icon>
<span>{{ 'MENU.SUPPORT_PROJECT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'MENU.ABOUT_ARIA' | translate"
(click)="openAboutDialog()"
>
<mat-icon>info</mat-icon>
<span>{{ 'MENU.ABOUT' | translate }}</span>
</button>
</mat-menu>
</ng-template>
<ngx-whats-new
*ngIf="!isElectron && isDialogVisible$ | async"
(closeModal)="setDialogVisibility(false)"
[items]="modals"
[options]="options"
/>
@if (!isElectron && isDialogVisible$ | async) {
<ngx-whats-new
(closeModal)="setDialogVisibility(false)"
[items]="modals"
[options]="options"
/>
}
@@ -58,7 +58,7 @@
}
.active-sort {
background-color: rgba(0, 0, 0, 0.04);
background-color: rgb(48 48 48);
}
.check-icon {
@@ -1,4 +1,4 @@
import { AsyncPipe, NgIf } from '@angular/common';
import { AsyncPipe } from '@angular/common';
import { Component, Input, OnInit, effect } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@@ -39,7 +39,6 @@ import {
MatMenuModule,
MatToolbarModule,
MatTooltipModule,
NgIf,
FormsModule,
NgxWhatsNewModule,
ReactiveFormsModule,
@@ -1,51 +1,52 @@
<div class="mpv-player-bar" *ngIf="(activeProcesses$ | async)?.length">
@if ((activeProcesses$ | async)?.length) {
<div class="mpv-player-bar">
@for (process of activeProcesses$ | async; track process.id) {
<div class="player-item">
<div class="player-media">
@if (process.thumbnail) {
<img
[src]="process.thumbnail"
[alt]="process.title"
class="media-thumbnail"
<div class="player-item">
<div class="player-media">
@if (process.thumbnail) {
<img
[src]="process.thumbnail"
[alt]="process.title"
class="media-thumbnail"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
/>
} @else {
<div class="media-thumbnail-placeholder">
<mat-icon>live_tv</mat-icon>
</div>
}
<div class="media-info">
<span class="media-title">{{ process.title }}</span>
<span class="media-url">{{ process.url }}</span>
</div>
</div>
<div class="player-controls">
<!-- <button
mat-icon-button
(click)="pauseStream(process.id)"
title="Pause"
>
<mat-icon>pause</mat-icon>
</button>
<button
mat-icon-button
(click)="playStream(process.id)"
title="Play"
>
<mat-icon>play_arrow</mat-icon>
</button> -->
<button
mat-icon-button
(click)="closeStream(process.id)"
title="Close"
>
<mat-icon>close</mat-icon>
</button>
/>
} @else {
<div class="media-thumbnail-placeholder">
<mat-icon>live_tv</mat-icon>
</div>
}
<div class="media-info">
<span class="media-title">{{ process.title }}</span>
<span class="media-url">{{ process.url }}</span>
</div>
</div>
}
<div class="player-controls">
<!-- <button
mat-icon-button
(click)="pauseStream(process.id)"
title="Pause"
>
<mat-icon>pause</mat-icon>
</button>
<button
mat-icon-button
(click)="playStream(process.id)"
title="Play"
>
<mat-icon>play_arrow</mat-icon>
</button> -->
<button
mat-icon-button
(click)="closeStream(process.id)"
title="Close"
>
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
</div>
}
@@ -44,12 +44,10 @@ export class RecentlyViewedComponent {
itemDetails: any = null;
readonly allFavorites = rxResource({
request: () => ({
params: () => ({
refreshTimestamp: this.refreshTimestamp(),
}),
loader: () => {
return this.playlistService.getPortalRecentlyViewed();
},
stream: () => this.playlistService.getPortalRecentlyViewed(),
});
readonly categories = computed(() => [
@@ -44,12 +44,10 @@ export class StalkerFavoritesComponent {
itemDetails: any = null;
readonly allFavorites = rxResource({
request: () => ({
params: () => ({
refreshTimestamp: this.refreshTimestamp(),
}),
loader: () => {
return this.playlistService.getPortalFavorites();
},
stream: () => this.playlistService.getPortalFavorites(),
});
readonly categories = computed(() => [
@@ -89,16 +89,18 @@ export class StalkerSearchComponent {
itemDetails = null;
readonly searchResultsResource = resource({
request: () => ({
params: () => ({
contentType: this.selectedFilterType(),
search: this.searchTerm(),
action: StalkerPortalActions.GetOrderedList,
}),
loader: async ({ request }) => {
if (request.search.length < 3) {
loader: async ({ params }) => {
if (params.search.length < 3) {
return [];
}
const { portalUrl, macAddress } = this.currentPlaylist();
const playlist = this.currentPlaylist();
if (!playlist) return [];
const { portalUrl, macAddress } = playlist;
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
@@ -106,10 +108,10 @@ export class StalkerSearchComponent {
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[request.contentType]
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: request.contentType,
search: request.search,
type: params.contentType,
search: params.search,
max_page_items: 100,
},
}
+27 -29
View File
@@ -85,15 +85,15 @@ export const StalkerStore = signalStore(
withState(initialState),
withProps((store, dataService = inject(DataService)) => ({
getCategoryResource: resource({
request: () => ({
params: () => ({
contentType: store.selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: store.currentPlaylist(),
}),
loader: async ({ request }) => {
if (request.currentPlaylist === undefined) return;
loader: async ({ params }) => {
if (params.currentPlaylist === undefined) return;
switch (request.contentType) {
switch (params.contentType) {
case 'itv':
if (store.itvCategories().length > 0) {
return store.itvCategories();
@@ -111,7 +111,7 @@ export const StalkerStore = signalStore(
break;
}
const { portalUrl, macAddress } = request.currentPlaylist;
const { portalUrl, macAddress } = params.currentPlaylist;
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
@@ -119,9 +119,9 @@ export const StalkerStore = signalStore(
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[request.contentType]
action: StalkerContentTypes[params.contentType]
.getCategoryAction,
type: request.contentType,
type: params.contentType,
},
}
);
@@ -135,7 +135,7 @@ export const StalkerStore = signalStore(
a.category_name.localeCompare(b.category_name)
);
patchState(store, {
[`${request.contentType}Categories`]: categories,
[`${params.contentType}Categories`]: categories,
});
return categories;
} else {
@@ -146,34 +146,32 @@ export const StalkerStore = signalStore(
},
}),
getContentResource: resource({
request: () => ({
params: () => ({
contentType: store.selectedContentType(),
category: store.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: store.searchPhrase(),
pageIndex: store.page() + 1,
}),
loader: async ({ request }) => {
loader: async ({ params }) => {
if (
!request.category ||
request.category === null ||
request.category === ''
!params.category ||
params.category === null ||
params.category === ''
) {
return Promise.resolve(undefined);
}
const currentPlaylist = store.currentPlaylist;
const params = {
action: StalkerContentTypes[request.contentType]
const queryParams = {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: request.contentType,
category: request.category ?? '',
genre: request.category ?? '',
type: params.contentType,
category: params.category ?? '',
genre: params.category ?? '',
sortby: 'added',
...(request.search !== ''
? { search: request.search }
: {}),
p: request.pageIndex,
...(params.search !== '' ? { search: params.search } : {}),
p: params.pageIndex,
};
const response = await dataService.sendIpcEvent(
@@ -181,7 +179,7 @@ export const StalkerStore = signalStore(
{
url: currentPlaylist().portalUrl,
macAddress: currentPlaylist().macAddress,
params,
params: queryParams,
}
);
@@ -195,7 +193,7 @@ export const StalkerStore = signalStore(
if (store.selectedContentType() === 'itv') {
// Check if we're loading the first page or loading more
if (request.pageIndex === 1) {
if (params.pageIndex === 1) {
patchState(store, { itvChannels: newItems });
} else {
patchState(store, {
@@ -223,22 +221,22 @@ export const StalkerStore = signalStore(
},
}),
serialSeasonsResource: resource({
request: () => ({
params: () => ({
itemId: store.selectedSerialId(),
}),
loader: async ({ request }) => {
loader: async ({ params }) => {
const { portalUrl, macAddress } = store.currentPlaylist();
const params = {
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: request.itemId,
movie_id: params.itemId,
};
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
params: queryParams,
}
);
return sortByNumericValue(response.js.data);
@@ -1,34 +1,24 @@
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 { TranslatePipe } 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',
imports: [
CommonModule,
MatButton,
MatDialogModule,
MatCardModule,
MatListModule,
TranslateModule,
],
imports: [MatButton, MatDialogModule, MatListModule, TranslatePipe],
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>
@if (accountInfo?.user_info?.message) {
<div class="welcome-message">
{{ accountInfo?.user_info?.message }}
</div>
}
<div class="info-grid">
<div class="info-section">
<h3>User Information</h3>
@@ -137,7 +127,7 @@ import { XtreamAccountInfo } from './account-info.interface';
</button>
</mat-dialog-actions>
`,
styleUrl: './account-info.component.scss'
styleUrl: './account-info.component.scss',
})
export class AccountInfoComponent {
accountInfo: XtreamAccountInfo;
@@ -37,7 +37,7 @@ export class CategoryContentViewComponent implements OnInit {
readonly limit = this.store.limit;
readonly pageIndex = this.store.page;
readonly pageSizeOptions = [5, 10, 25, 50, 100];
readonly pageSizeOptions = this.isStalker ? [14] : [10, 25, 50, 100];
readonly selectedCategory = this.store.getSelectedCategory;
readonly paginatedContent = this.store.getPaginatedContent;
readonly isPaginatedContentLoading = this.store.isPaginatedContentLoading;
@@ -1,10 +1,9 @@
import { CommonModule } from '@angular/common';
import { Component, input } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
@Component({
selector: 'app-loading-overlay',
imports: [MatProgressBarModule, CommonModule],
imports: [MatProgressBarModule],
template: `
<div class="overlay">
<div class="progress-container">
@@ -47,7 +46,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
}
}
`,
]
],
})
export class LoadingOverlayComponent {
current = input(0);
@@ -6,7 +6,7 @@
}
.link-input {
width: 98%;
width: 100%;
padding-top: 5px;
@include mat.form-field-density(-5);
}
@@ -19,4 +19,4 @@ mat-dialog-content {
.video-js {
height: 500px !important;
}
}
}
@@ -1,110 +1,119 @@
<div id="navigation">
<button mat-button routerLink="/">
<mat-icon>home</mat-icon>
<span class="button-label">{{ 'PLAYLISTS' | translate }}</span>
<button mat-button routerLink="/">
<mat-icon>home</mat-icon>
<span class="button-label">{{ 'PLAYLISTS' | translate }}</span>
</button>
@if (showCategories) {
<mat-button-toggle-group
name="contentType"
aria-label="Content Type"
[value]="contentType"
(change)="changeContentType($event.value)"
hideSingleSelectionIndicator
>
@for (
item of contentTypeNavigationItems; track trackByValue($index,
item)) {
<mat-button-toggle
[value]="item.contentType"
>
<mat-icon class="icon">{{ item.icon }}</mat-icon>
<span class="label">{{ item.label }}</span>
</mat-button-toggle>
}
</mat-button-toggle-group>
}
<div>
<button mat-button (click)="openPlaylistDetails()">
<mat-icon>info</mat-icon>
<span class="button-label">{{ 'Info' | translate }}</span>
</button>
<mat-button-toggle-group
*ngIf="showCategories"
name="contentType"
aria-label="Content Type"
[value]="contentType"
(change)="changeContentType($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle
*ngFor="
let item of contentTypeNavigationItems;
trackBy: trackByValue
"
[value]="item.contentType"
>
<mat-icon class="icon">{{ item.icon }}</mat-icon>
<span class="label">{{ item.label }}</span>
</mat-button-toggle>
</mat-button-toggle-group>
<button mat-button (click)="favoritesClicked.emit()">
<mat-icon>star</mat-icon>
<span class="button-label">{{
'CHANNELS.FAVORITES' | translate
}}</span>
</button>
<div>
<button mat-button (click)="openPlaylistDetails()">
<mat-icon>info</mat-icon>
<span class="button-label">{{ 'Info' | translate }}</span>
</button>
<button mat-button (click)="favoritesClicked.emit()">
<mat-icon>star</mat-icon>
<span class="button-label">{{
'CHANNELS.FAVORITES' | translate
}}</span>
</button>
<button mat-button (click)="openSettings()">
<mat-icon>settings</mat-icon>
<span class="button-label">{{ 'MENU.SETTINGS' | translate }}</span>
</button>
</div>
<button mat-button (click)="openSettings()">
<mat-icon>settings</mat-icon>
<span class="button-label">{{ 'MENU.SETTINGS' | translate }}</span>
</button>
</div>
</div>
<div id="sub-panel">
<div class="breadcrumbs">
<div
*ngFor="let item of breadcrumbs; let i = index"
(click)="processBreadcrumbClick(item)"
<div class="breadcrumbs">
@for (item of breadcrumbs; track item; let i = $index) {
<div
(click)="processBreadcrumbClick(item)"
>
<span>{{ item.title }}</span>
<mat-icon *ngIf="i !== breadcrumbs.length - 1"
>chevron_right</mat-icon
<span>{{ item.title }}</span>
@if (i !== breadcrumbs.length - 1) {
<mat-icon
>chevron_right</mat-icon
>
}
</div>
}
</div>
<div class="search-container">
<div class="favorite">
<button
*ngIf="favoriteVisible && activeLiveStream"
mat-icon-button
(click)="clickFavorites()"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
<div class="favorite">
@if (favoriteVisible && activeLiveStream) {
<button
mat-icon-button
(click)="clickFavorites()"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{ isFavoriteStream ? 'star' : 'star_outline' }}
</mat-icon>
</button>
</div>
<div class="sort" *ngIf="sortVisible">
<button
mat-icon-button
[matTooltip]="'CHANNELS.SORT_BY' | translate"
[matMenuTriggerFor]="sortPlaylistMenu"
data-test-id="sort-playlist"
<mat-icon>
{{ isFavoriteStream ? 'star' : 'star_outline' }}
</mat-icon>
</button>
}
</div>
@if (sortVisible) {
<div class="sort">
<button
mat-icon-button
[matTooltip]="'CHANNELS.SORT_BY' | translate"
[matMenuTriggerFor]="sortPlaylistMenu"
data-test-id="sort-playlist"
>
<mat-icon>sort</mat-icon>
</button>
<mat-icon>sort</mat-icon>
</button>
</div>
<div class="search" *ngIf="searchVisible">
<mat-form-field class="search-input">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
placeholder="Search by name"
type="search"
[ngModel]="searchPhrase()"
(ngModelChange)="searchPhraseUpdate.next($event)"
/>
</mat-form-field>
}
@if (searchVisible) {
<div class="search">
<mat-form-field class="search-input">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
placeholder="Search by name"
type="search"
[ngModel]="searchPhrase()"
(ngModelChange)="searchPhraseUpdate.next($event)"
/>
</mat-form-field>
</div>
}
</div>
<mat-menu #sortPlaylistMenu="matMenu">
<section (click)="$event.stopPropagation()">
<button mat-menu-item (click)="setSortType('date')">
<mat-icon>calendar_today</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_DATE' | translate }}</span>
</button>
<button mat-menu-item (click)="setSortType('rating')">
<mat-icon>star</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_RATING' | translate }}</span>
</button>
<button mat-menu-item (click)="setSortType('alpha')">
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_NAME' | translate }}</span>
</button>
</section>
<section (click)="$event.stopPropagation()">
<button mat-menu-item (click)="setSortType('date')">
<mat-icon>calendar_today</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_DATE' | translate }}</span>
</button>
<button mat-menu-item (click)="setSortType('rating')">
<mat-icon>star</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_RATING' | translate }}</span>
</button>
<button mat-menu-item (click)="setSortType('alpha')">
<mat-icon>sort_by_alpha</mat-icon>
<span>{{ 'CHANNELS.SORT_BY_NAME' | translate }}</span>
</button>
</section>
</mat-menu>
</div>
</div>
@@ -1,4 +1,4 @@
import { NgFor, NgIf } from '@angular/common';
import {
Component,
EventEmitter,
@@ -44,20 +44,18 @@ import { PortalStore } from '../portal.store';
templateUrl: './navigation-bar.component.html',
styleUrls: ['./navigation-bar.component.scss'],
imports: [
FormsModule,
MatButtonModule,
MatButtonToggleModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
NgFor,
NgIf,
RouterLink,
TranslateModule,
MatMenuModule,
MatCheckboxModule,
MatTooltipModule,
],
FormsModule,
MatButtonModule,
MatButtonToggleModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
RouterLink,
TranslateModule,
MatMenuModule,
MatCheckboxModule,
MatTooltipModule
],
})
export class NavigationBarComponent implements OnChanges {
@Input({ required: true }) breadcrumbs: Breadcrumb[];
@@ -1,30 +1,33 @@
<h3>Seasons and episodes</h3>
<div class="seasons-container">
<ng-container *ngIf="!selectedSeason; else episodesView">
<mat-card
class="season-item"
*ngFor="let season of seasons | keyvalue"
(click)="selectSeason(season.key)"
@if (!selectedSeason) {
@for (season of seasons | keyvalue; track season) {
<mat-card
class="season-item"
(click)="selectSeason(season.key)"
>
<mat-card-content>Season {{ season.key }}</mat-card-content>
</mat-card>
</ng-container>
<mat-card-content>Season {{ season.key }}</mat-card-content>
</mat-card>
}
} @else {
<mat-card class="episode-item" (click)="selectedSeason = undefined">
<div class="episode-title">< Back to seasons</div>
</mat-card>
@for (episode of seasons[selectedSeason]; track episode) {
<mat-card
class="episode-item"
(click)="selectEpisode(episode)"
>
@if (episode.info.movie_image) {
<mat-card-content
><img [src]="episode.info.movie_image" class="episode-cover"
/></mat-card-content>
}
<div class="episode-title">
{{ episode.episode_num }}. {{ episode.title }}
</div>
</mat-card>
}
}
<ng-template #episodesView>
<mat-card class="episode-item" (click)="selectedSeason = undefined">
<div class="episode-title">< Back to seasons</div>
</mat-card>
<mat-card
class="episode-item"
*ngFor="let episode of seasons[selectedSeason]"
(click)="selectEpisode(episode)"
>
<mat-card-content *ngIf="episode.info.movie_image"
><img [src]="episode.info.movie_image" class="episode-cover"
/></mat-card-content>
<div class="episode-title">
{{ episode.episode_num }}. {{ episode.title }}
</div>
</mat-card>
</ng-template>
</div>
@@ -1,4 +1,4 @@
import { KeyValuePipe, NgFor, NgIf } from '@angular/common';
import { KeyValuePipe } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { XtreamSerieEpisode } from '../../../../shared/xtream-serie-details.interface';
@@ -7,7 +7,7 @@ import { XtreamSerieEpisode } from '../../../../shared/xtream-serie-details.inte
selector: 'app-season-container',
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
imports: [KeyValuePipe, NgFor, MatCardModule, NgIf],
imports: [KeyValuePipe, MatCardModule],
})
export class SeasonContainerComponent {
@Input({ required: true }) seasons: Record<string, XtreamSerieEpisode[]>;
@@ -1,5 +1,5 @@
<div
class="container"
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(' +
@@ -7,67 +7,83 @@
') repeat-x'
: 'none'
"
>
<div class="image">
<img *ngIf="item.info.cover" [src]="item.info.cover" />
</div>
<div class="details">
<h2>{{ item.info.name }}</h2>
<label *ngIf="item.info.plot">
{{ 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 class="image">
@if (item.info.cover) {
<img [src]="item.info.cover" />
}
</div>
<div class="details">
<h2>{{ item.info.name }}</h2>
@if (item.info.plot) {
<label>
{{ item.info.plot }}
</label>
}
@if (item.info.releaseDate) {
<label>
<div class="label">{{ 'XTREAM.RELEASE_DATE' | translate }}:</div>
{{ item.info.releaseDate }}
</label>
}
@if (item.info.genre) {
<label>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item.info.genre }}
</label>
}
@if (item.info.rating) {
<label>
<div class="label">{{ 'XTREAM.RATING' | translate }}:</div>
{{ item.info.rating }}
</label>
}
@if (item.info.cast) {
<div>
<div class="label">{{ 'XTREAM.CAST' | translate }}:</div>
{{ item.info.cast }}
</div>
}
@if (item.info.director) {
<label>
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item.info.director }}
</label>
}
@if (item.info.episode_run_time) {
<label>
<div class="label">
{{ 'XTREAM.EPISODE_RUN_TIME' | translate }}:
</div>
<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">
<button
*ngIf="!isFavorite; else removeFromFavoritesButton"
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
{{ 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>
}
<ng-template #removeFromFavoritesButton>
<button
mat-flat-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
</ng-template>
</div>
</div>
</div>
</div>
<app-season-container
[seasons]="item.episodes"
(episodeClicked)="playClicked.emit($event)"
/>
[seasons]="item.episodes"
(episodeClicked)="playClicked.emit($event)"
/>
@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@@ -18,12 +18,11 @@ import { SeasonContainerComponent } from '../season-container/season-container.c
templateUrl: './serial-details.component.html',
styleUrls: ['../detail-view.scss'],
imports: [
MatButtonModule,
MatIconModule,
NgIf,
SeasonContainerComponent,
TranslateModule,
],
MatButtonModule,
MatIconModule,
SeasonContainerComponent,
TranslateModule
],
})
export class SerialDetailsComponent {
@Input({ required: true }) item: XtreamSerieDetails;
@@ -1,5 +1,5 @@
<div
class="container"
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(' +
@@ -7,93 +7,112 @@
') repeat-x'
: 'none'
"
>
<div class="image">
@if (item.info?.movie_image) {
<img
[src]="item.info?.movie_image"
>
<div class="image">
@if (item.info?.movie_image) {
<img
[src]="item.info?.movie_image"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
/>
/>
} @else {
<div class="placeholder-cover"></div>
}
</div>
<div class="details">
<h2>{{ item.info.name }}</h2>
@if (item.info.description) {
<label>
{{ item.info.description }}
</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) {
<div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ item.info.actors }}
</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)="playClicked.emit(item)"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}</button
>&nbsp;
@if (!isFavorite) {
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<div class="placeholder-cover"></div>
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
</div>
<div class="details">
<h2>{{ item.info.name }}</h2>
<label *ngIf="item.info.description">
{{ item.info.description }}
</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.country">
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
{{ item.info.country }}
</label>
<div *ngIf="item.info.actors">
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ item.info.actors }}
</div>
<label *ngIf="item.info.director">
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item.info.director }}
</label>
<label *ngIf="item.info.duration">
<div class="label">{{ 'XTREAM.DURATION' | translate }}:</div>
{{ item.info.duration }}
</label>
<label *ngIf="item.info.rating_imdb">
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
{{ item.info.rating_imdb }}
</label>
<label *ngIf="item.info.rating_kinopoisk">
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ item.info.rating_kinopoisk }}
</label>
<div class="action-buttons">
<button
mat-flat-button
color="accent"
(click)="playClicked.emit(item)"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}</button
>&nbsp;
<button
*ngIf="!isFavorite; else removeFromFavoritesButton"
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
<ng-template #removeFromFavoritesButton>
<button
mat-stroked-button
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
</ng-template>
</div>
</div>
</div>
</div>
<div class="youtube-trailer" *ngIf="item.info.youtube_trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<iframe
</div>
@if (item.info.youtube_trailer) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<iframe
width="100%"
height="450"
[src]="
@@ -104,5 +123,6 @@
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
></iframe>
</div>
}
@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common';
import {
Component,
EventEmitter,
@@ -21,13 +21,12 @@ import { SafePipe } from './safe.pipe';
templateUrl: './vod-details.component.html',
styleUrls: ['../detail-view.scss'],
imports: [
MatButtonModule,
MatIcon,
MatProgressSpinnerModule,
NgIf,
SafePipe,
TranslatePipe,
],
MatButtonModule,
MatIcon,
MatProgressSpinnerModule,
SafePipe,
TranslatePipe
],
})
export class VodDetailsComponent implements OnInit {
@Input({ required: true }) item: XtreamVodDetails;
@@ -108,7 +108,7 @@ type LayoutView =
PlaylistErrorViewComponent,
TranslateModule,
LiveStreamLayoutComponent,
]
],
})
export class XtreamMainContainerComponent implements OnInit {
private readonly dataService = inject(DataService);
@@ -359,6 +359,8 @@ export class XtreamMainContainerComponent implements OnInit {
{
data: { streamUrl, title },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
}
);
}
@@ -385,6 +387,8 @@ export class XtreamMainContainerComponent implements OnInit {
this.dialog.open(PlayerDialogComponent, {
data: { streamUrl, player, title: episode.title },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
});
}
}
+3 -14
View File
@@ -1,5 +1,5 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app.module';
import { AppConfig } from './environments/environment';
@@ -7,17 +7,6 @@ if (AppConfig.production) {
enableProdMode();
}
// Detect if running in Tauri
const isTauri = window && (window as any).__TAURI__;
platformBrowserDynamic()
.bootstrapModule(AppModule, {
preserveWhitespaces: false,
})
.then(() => {
// Only register service worker for web builds
if (!isTauri && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/ngsw-worker.js');
}
})
platformBrowser()
.bootstrapModule(AppModule)
.catch((err) => console.error(err));
+22 -15
View File
@@ -1,21 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"module": "es2020",
"outDir": "./dist/out-tsc",
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2022",
"typeRoots": ["node_modules/@types"],
"lib": ["es2017", "es2016", "es2015", "dom"],
"resolveJsonModule": true,
"strict": false,
"skipLibCheck": true,
"useDefineForClassFields": false
"module": "es2020",
"outDir": "./dist/out-tsc",
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"moduleResolution": "bundler",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2022",
"typeRoots": [
"node_modules/@types"
],
"lib": [
"es2017",
"es2016",
"es2015",
"dom"
],
"resolveJsonModule": true,
"strict": false,
"skipLibCheck": true,
"useDefineForClassFields": false
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"],