Merge pull request #334 from 4gray/feat/angular-17-update

Update to Angular 17 and other minor changes
This commit is contained in:
4gray authored and GitHub committed 2023-12-08 20:58:36 +01:00
commit ba718b0537
52 files changed
+9302 -6947

No files matched your search

-1
View File
@@ -49,7 +49,6 @@
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/no-duplicate-imports": ["error"],
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/no-unsafe-argument": 0,
"@typescript-eslint/no-var-requires": 0,
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: '18.10'
node-version: '18'
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
- run: npm ci
- run: npm test -- --coverage
@@ -41,7 +41,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: '18.10'
node-version: '18'
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
- run: npm ci
# env:
@@ -70,7 +70,7 @@ jobs:
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v1
with:
node-version: '18.10'
node-version: '18'
- name: Build Electron app
uses: samuelmeuli/action-electron-builder@v1
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v2
with:
node-version: '18.10'
node-version: '18'
- name: Install Snapcraft
uses: samuelmeuli/action-snapcraft@v2
+1 -1
View File
@@ -5,5 +5,5 @@
npx stylelint "**/*.scss"
npx --no-install commitlint --edit
npm test -- --coverage
npm test
npx eslint src/**/*.ts
+6 -6
View File
@@ -18,10 +18,10 @@ The application is a cross-platform and open source project based on Electron an
## Features
- M3u and M3u8 playlists support 📺
- Upload playlists from a file system 📂
- Add remote playlists via URL 🔗
- Playlists auto-update feature
- Open playlist from the file system
- Xtream Code (XC) and Stalker portal (STB) support
- External player support - mvp, VLC
- Add playlists from file system or from remote URL 📂
- Playlists auto-update feature on app startup
- Search for channels 🔍
- EPG support (TV Guide) with detailed info
- TV archive/catchup/timeshift
@@ -29,10 +29,10 @@ The application is a cross-platform and open source project based on Electron an
- Save channels as favorites
- Global favorites aggregated from all playlists
- HTML video player with hls.js support or Video.js based player
- Internalization, currently 7 languages are supported (en, ru, de, ko, es, zh, fr)
- Internalization, currently 8 languages are supported (en, ru, de, ko, es, zh, fr, it)
- Set custom "User Agent" header for a playlist
- Re-fetch/auto-update playlists
- Light and Dark theme
- Version for self-hosted docker is available
## Screenshots:
+7 -10
View File
@@ -95,32 +95,29 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "iptvnator:build"
"buildTarget": "iptvnator:build"
},
"configurations": {
"dev": {
"browserTarget": "iptvnator:build:dev"
"buildTarget": "iptvnator:build:dev"
},
"web": {
"browserTarget": "iptvnator:build:web"
"buildTarget": "iptvnator:build:web"
},
"production": {
"browserTarget": "iptvnator:build:production"
"buildTarget": "iptvnator:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "iptvnator:build"
"buildTarget": "iptvnator:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:jest",
"options": {
"tsConfig": "tsconfig.spec.json",
"polyfills": ["zone.js", "zone.js/testing"]
}
"builder": "@angular-builders/jest:run",
"options": {}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
+6
View File
@@ -113,6 +113,12 @@ export class Api {
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
payload: playlistObject,
});
})
.catch((err) => {
event.sender.send(ERROR, {
message: err.response.statusText,
status: err.response.status,
});
});
} catch (err) {
event.sender.send(ERROR, {
+1 -13
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-useless-catch */
import { app, BrowserWindow, Menu, session } from 'electron';
import { app, BrowserWindow, Menu } from 'electron';
import * as path from 'path';
import * as url from 'url';
import { Api } from './api';
@@ -146,18 +146,6 @@ try {
app.on('before-quit', () => {
if (win) store.set(WINDOW_BOUNDS, win.getNormalBounds());
});
if (serve && process.platform === 'darwin') {
// add redux dev tools extension
const reduxDevToolsPath = path.join(
os.homedir(),
'Library/Application Support/Google/Chrome/Default/Extensions/lmhkpmbekcpmknklioeibfkpmmfibljd/3.0.19_14'
);
app.whenReady().then(async () => {
await session.defaultSession.loadExtension(reduxDevToolsPath);
});
}
} catch (e) {
// Catch Error
throw e;
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
preset: 'jest-preset-angular',
resetMocks: true,
setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
testMatch: ['**/+(*.)+(spec).+(ts)?(x)'],
coverageReporters: ['html', 'lcov'],
transformIgnorePatterns: ['node_modules/(?!.*.mjs$|@datorama/akita)'],
};
+8440 -6242
View File
File diff suppressed because it is too large. Load diff
+51 -48
View File
@@ -33,14 +33,14 @@
"ng:serve": "ng serve -c dev",
"ng:serve:web": "ng serve -c web -o",
"electron:serve-tsc": "tsc -p tsconfig.serve.json",
"electron:serve": "wait-on tcp:4200 && npm run electron:serve-tsc && npx electron . --serve",
"electron:serve": "wait-on tcp:4200 && npm run electron:serve-tsc && npx electron . --serve --inspect",
"electron:local": "npm run build:prod && npx electron .",
"electron:build": "npm run build:prod && electron-builder build",
"electron:build:mac": "npm run build:prod && electron-builder build -m",
"electron:build:linux": "npm run build:prod && electron-builder build -l --x64 --ia32 --arm64 --armv7l",
"electron:build:windows": "npm run build:prod && electron-builder build -w",
"test": "jest",
"test:watch": "jest --watch",
"test": "ng test",
"test:watch": "ng test --watch",
"e2e": "npm run build:electron && npx playwright test --retries 3",
"e2e:ci": "npx playwright test --retries 3 --workers=1",
"e2e:debug": "npm run build:electron && PWDEBUG=1 npx playwright test -c playwright.config.ts e2e/ /",
@@ -51,92 +51,95 @@
"build-pwa": "ng build -c production --base-href /iptvnator --deploy-url /iptvnator/"
},
"dependencies": {
"@angular/animations": "16.1.5",
"@angular-builders/jest": "17.0.0",
"@angular/animations": "17.0.1",
"@angular/cdk": "16.1.5",
"@angular/material": "16.1.5",
"@electron/remote": "2.0.10",
"@ngrx/component-store": "16.1.0",
"@ngrx/effects": "16.1.0",
"@ngrx/entity": "16.1.0",
"@ngrx/router-store": "16.1.0",
"@ngrx/store": "16.1.0",
"@ngrx/store-devtools": "16.1.0",
"@ngx-pwa/local-storage": "16.0.0",
"@electron/remote": "2.1.0",
"@ngrx/component-store": "17.0.0",
"@ngrx/effects": "17.0.0",
"@ngrx/entity": "17.0.0",
"@ngrx/router-store": "17.0.0",
"@ngrx/store": "17.0.0",
"@ngrx/store-devtools": "17.0.0",
"@ngx-pwa/local-storage": "17.0.0",
"@rx-angular/cdk": "17.0.0",
"@rx-angular/template": "17.0.0",
"@videojs/http-streaming": "2.15.0",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"about-window": "1.15.2",
"axios": "0.21.2",
"axios": "1.6.2",
"custom-electron-titlebar": "4.2.3",
"date-fns": "2.29.3",
"electron-context-menu": "3.6.1",
"electron-store": "8.1.0",
"epg-parser": "0.1.6",
"hls.js": "1.2.7",
"hls.js": "1.4.12",
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser",
"lodash": "4.17.21",
"moment": "2.29.4",
"nedb-promises": "6.0.3",
"ngx-indexed-db": "16.0.0",
"ngx-skeleton-loader": "7.0.0",
"ngx-skeleton-loader": "8.1.0",
"ngx-whats-new": "0.4.0",
"node-mpv": "github:4gray/Node-MPV",
"rxjs": "7.8.0",
"rxjs": "7.8.1",
"semver": "7.3.8",
"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.13.0"
"zone.js": "~0.14.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "16.1.4",
"@angular-eslint/builder": "16.1.0",
"@angular-eslint/eslint-plugin": "16.1.0",
"@angular-eslint/eslint-plugin-template": "16.1.0",
"@angular-eslint/schematics": "16.1.0",
"@angular-eslint/template-parser": "16.1.0",
"@angular/cli": "16.1.4",
"@angular/common": "16.1.5",
"@angular/compiler": "16.1.5",
"@angular/compiler-cli": "16.1.5",
"@angular/core": "16.1.5",
"@angular/forms": "16.1.5",
"@angular/language-service": "16.1.5",
"@angular/platform-browser": "16.1.5",
"@angular/platform-browser-dynamic": "16.1.5",
"@angular/router": "16.1.5",
"@angular/service-worker": "16.1.5",
"@commitlint/cli": "16.0.1",
"@commitlint/config-conventional": "16.0.0",
"@ngrx/eslint-plugin": "^16.1.0",
"@ngx-translate/core": "14.0.0",
"@ngx-translate/http-loader": "7.0.0",
"@angular-devkit/build-angular": "17.0.0",
"@angular-eslint/builder": "17.0.0",
"@angular-eslint/eslint-plugin": "17.0.0",
"@angular-eslint/eslint-plugin-template": "17.0.0",
"@angular-eslint/schematics": "17.0.0",
"@angular-eslint/template-parser": "17.0.0",
"@angular/cli": "17.0.0",
"@angular/common": "17.0.1",
"@angular/compiler": "17.0.1",
"@angular/compiler-cli": "17.0.1",
"@angular/core": "17.0.1",
"@angular/forms": "17.0.1",
"@angular/language-service": "17.0.1",
"@angular/platform-browser": "17.0.1",
"@angular/platform-browser-dynamic": "17.0.1",
"@angular/router": "17.0.1",
"@angular/service-worker": "17.0.1",
"@commitlint/cli": "18.4.3",
"@commitlint/config-conventional": "18.4.3",
"@ngrx/eslint-plugin": "^17.0.0",
"@ngx-translate/core": "15.0.0",
"@ngx-translate/http-loader": "8.0.0",
"@playwright/test": "1.37.1",
"@semantic-release/changelog": "6.0.1",
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "9.0.0",
"@types/jest": "29.5.1",
"@types/mocha": "9.0.0",
"@types/node": "18.11.18",
"@types/node": "20.10.0",
"@types/video.js": "7.3.29",
"@types/videojs-hls-quality-selector": "1.1.0",
"@typescript-eslint/eslint-plugin": "5.40.1",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/eslint-plugin-tslint": "5.40.1",
"@typescript-eslint/parser": "5.40.1",
"@typescript-eslint/parser": "^6.10.0",
"conventional-changelog-cli": "2.2.2",
"electron": "25.2.0",
"electron-builder": "24.4.0",
"eslint": "8.26.0",
"electron": "27.1.0",
"electron-builder": "24.6.4",
"eslint": "^8.53.0",
"eslint-plugin-import": "2.26.0",
"husky": "5.0.9",
"jest": "29.5.0",
"jest-preset-angular": "13.1.0",
"ng-mocks": "14.10.0",
"ng-mocks": "14.12.1",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
"playwright": "1.37.1",
"playwright-core": "1.37.1",
"prettier": "3.0.0",
"prettier": "3.1.0",
"semantic-release": "19.0.3",
"stylelint": "14.2.0",
"stylelint-config-standard": "24.0.0",
@@ -144,7 +147,7 @@
"ts-jest": "29.1.0",
"ts-node": "10.9.1",
"tslib": "2.3.0",
"typescript": "5.0.4",
"typescript": "5.2.2",
"wait-on": "6.0.0"
},
"engines": {
@@ -1,7 +1,6 @@
$electron-title-width: 28px;
.file-upload {
height: calc(100vh - 230px);
cursor: pointer;
border: 1px dashed #999;
border-radius: 5px;
@@ -10,6 +9,7 @@ $electron-title-width: 28px;
align-items: center;
justify-content: center;
margin: 20px;
padding: 10px;
gap: 20px;
.upload-icon {
@@ -1,6 +1,10 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
@Component({
standalone: true,
imports: [MatIconModule, TranslateModule],
selector: 'app-file-upload',
templateUrl: './file-upload.component.html',
styleUrls: ['./file-upload.component.scss'],
@@ -12,8 +16,8 @@ export class FileUploadComponent {
file: File;
}> = new EventEmitter();
/** Emits on reject event */
@Output() fileRejected: EventEmitter<string> = new EventEmitter();
@Output() addClicked = new EventEmitter<void>();
allowedContentTypes = [
'application/mpegurl',
@@ -37,5 +41,6 @@ export class FileUploadComponent {
file: fileList[0],
});
fileReader.readAsText(fileList[0]);
this.addClicked.emit();
}
}
+1 -80
View File
@@ -1,89 +1,10 @@
<div class="grid-container">
<!-- Application header-->
<app-header
[title]="'HOME.TITLE' | translate"
[subtitle]="'HOME.SUBTITLE' | translate"
></app-header>
<!-- Tab-based layout -->
<mat-tab-group
[dynamicHeight]="true"
color="primary"
backgroundColor="primary"
mat-stretch-tabs
>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.RECENTLY_ADDED' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-recent-playlists class="recent-playlists">
</app-recent-playlists>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">attach_file</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.FILE_UPLOAD' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-file-upload
(fileRejected)="rejectFile($event)"
(fileSelected)="handlePlaylist($event)"
/>
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">link</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.URL_UPLOAD' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-url-upload (urlAdded)="sendPlaylistsUrl($event)" />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">text_format</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.TEXT_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-text-import (textAdded)="uploadAsText($event)" />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">video_library</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.XTREME_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-xtream-code-import />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">dashboard</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-stalker-portal-import />
</ng-template>
</mat-tab>
</mat-tab-group>
<app-recent-playlists class="recent-playlists" />
<!-- Loading spinner -->
<mat-progress-bar *ngIf="isLoading" mode="indeterminate"></mat-progress-bar>
</div>
+1 -1
View File
@@ -13,7 +13,7 @@
}
.recent-playlists {
height: calc(100vh - 152px);
height: calc(100vh - 106px);
overflow: auto;
width: 100%;
display: block;
+9 -57
View File
@@ -3,39 +3,30 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
TranslateModule,
TranslatePipe,
TranslateService,
} from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable, of } from 'rxjs';
import { Observable } from 'rxjs';
import { FileUploadComponent } from '../home/file-upload/file-upload.component';
import { RecentPlaylistsComponent } from '../home/recent-playlists/recent-playlists.component';
import { UrlUploadComponent } from '../home/url-upload/url-upload.component';
import { DataService } from '../services/data.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { HeaderComponent } from '../shared/components/header/header.component';
import { PLAYLIST_PARSE_BY_URL } from './../../../shared/ipc-commands';
import { HomeComponent } from './home.component';
class MatSnackBarStub {
open(): void {}
}
class NgxIndexedDBServiceStub {
getAll() {
return of([] as any);
}
}
describe('HomeComponent', () => {
let component: HomeComponent;
let fixture: ComponentFixture<HomeComponent>;
let electronService: DataService;
let router: Router;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
@@ -56,15 +47,13 @@ describe('HomeComponent', () => {
MockModule(MatSnackBarModule),
MockModule(RouterTestingModule),
MockModule(NgxIndexedDBModule),
MockModule(TranslateModule),
],
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
MockProvider(MatSnackBar),
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(TranslateService),
{
provide: NgxIndexedDBService,
useClass: NgxIndexedDBServiceStub,
},
MockProvider(NgxIndexedDBService),
provideMockStore(),
provideMockActions(actions$),
],
@@ -79,7 +68,6 @@ describe('HomeComponent', () => {
mockStore = TestBed.inject(MockStore);
mockStore.setState({});
router = TestBed.inject(Router);
TestBed.inject(NgxIndexedDBService);
fixture.detectChanges();
});
@@ -88,36 +76,6 @@ describe('HomeComponent', () => {
expect(component).toBeTruthy();
});
it('should send an event to the main process to get a playlist by URL', () => {
const playlistTitle = 'playlist.m3u';
const playlistUrl = 'http://test.com/' + playlistTitle;
jest.spyOn(electronService, 'sendIpcEvent');
component.sendPlaylistsUrl(playlistUrl);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_PARSE_BY_URL,
{
title: playlistTitle,
url: playlistUrl,
}
);
});
it('should send an event to the main process to parse a playlist', () => {
jest.spyOn(mockStore, 'dispatch');
const title = 'my-list.m3u';
const path = '/home/user/iptv/' + title;
const playlistContent = 'test';
const file: File = {
path,
name: title,
} as unknown as File;
const uploadEvent: Event = {
target: { result: playlistContent },
} as unknown as Event;
component.handlePlaylist({ file, uploadEvent });
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
it('should set IPC event listeners', () => {
jest.spyOn(electronService, 'listenOn');
component.setRendererListeners();
@@ -126,12 +84,6 @@ describe('HomeComponent', () => {
);
});
it('should send notification on file reject', () => {
jest.spyOn(component, 'showNotification');
component.rejectFile('wrong-file.txt');
expect(component.showNotification).toHaveBeenCalledTimes(1);
});
it('should remove all ipc listeners on destroy', () => {
jest.spyOn(electronService, 'removeAllListeners');
component.ngOnDestroy();
+7 -70
View File
@@ -1,16 +1,10 @@
import { Component, NgZone } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
ERROR,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
} from '../../../shared/ipc-commands';
import { ERROR, PLAYLIST_PARSE_RESPONSE } from '../../../shared/ipc-commands';
import { Playlist } from '../../../shared/playlist.interface';
import { getFilenameFromUrl } from '../../../shared/playlist.utils';
import { DataService } from '../services/data.service';
import { addPlaylist, parsePlaylist } from '../state/actions';
import { addPlaylist } from '../state/actions';
@Component({
selector: 'app-home',
@@ -35,7 +29,10 @@ export class HomeComponent {
},
{
id: ERROR,
execute: () => (this.isLoading = false),
execute: (error: { message: string; status: string }) => {
//this.isLoading = false;
this.showNotification('Error: ' + error.message);
},
},
];
@@ -45,8 +42,7 @@ export class HomeComponent {
private dataService: DataService,
private ngZone: NgZone,
private snackBar: MatSnackBar,
private readonly store: Store,
private translateService: TranslateService
private readonly store: Store
) {
this.setRendererListeners();
}
@@ -72,65 +68,6 @@ export class HomeComponent {
});
}
/**
* Shows the filename of rejected file
* @param filename name of the uploaded file
*/
rejectFile(filename: string): void {
this.showNotification(
this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
filename,
})
);
this.isLoading = false;
}
/**
* Parse and store uploaded playlist
* @param payload
*/
handlePlaylist(payload: { uploadEvent: Event; file: File }): void {
this.isLoading = true;
const playlist = (payload.uploadEvent.target as FileReader)
.result as string;
this.store.dispatch(
parsePlaylist({
uploadType: 'FILE',
playlist,
title: payload.file.name,
path: payload.file.path,
})
);
}
/**
* Sends url of the playlist to the renderer process
* @param playlistUrl url of the added playlist
*/
sendPlaylistsUrl(playlistUrl: string): void {
this.isLoading = true;
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
title: getFilenameFromUrl(playlistUrl),
url: playlistUrl,
});
}
/**
* Sends IPC event to the renderer process to parse playlist
* @param text playlist as string
*/
uploadAsText(playlist: string): void {
this.isLoading = true;
this.store.dispatch(
parsePlaylist({
uploadType: 'TEXT',
playlist,
title: 'Imported as text',
})
);
}
/**
* Shows snack bar notification with a given message
* @param message message to show
+3 -19
View File
@@ -1,29 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { HeaderComponent } from '../shared/components/header/header.component';
import { SharedModule } from '../shared/shared.module';
import { DragDropFileUploadDirective } from './file-upload/drag-drop-file-upload.directive';
import { FileUploadComponent } from './file-upload/file-upload.component';
import { HomeComponent } from './home.component';
import { HomeRoutingModule } from './home.routing';
import { StalkerPortalImportComponent } from './stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from './text-import/text-import.component';
import { UrlUploadComponent } from './url-upload/url-upload.component';
import { XtreamCodeImportComponent } from './xtream-code-import/xtream-code-import.component';
@NgModule({
imports: [
CommonModule,
HomeRoutingModule,
SharedModule,
XtreamCodeImportComponent,
StalkerPortalImportComponent,
],
declarations: [
DragDropFileUploadDirective,
HomeComponent,
FileUploadComponent,
TextImportComponent,
UrlUploadComponent,
],
imports: [CommonModule, HeaderComponent, HomeRoutingModule, SharedModule],
declarations: [DragDropFileUploadDirective, HomeComponent],
})
export class HomeModule {}
@@ -9,10 +9,11 @@ import {
} from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable } from 'rxjs';
@@ -27,29 +28,30 @@ describe('PlaylistInfoComponent', () => {
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
FormsModule,
MockModule(MatDialogModule),
MockModule(MatCheckboxModule),
MockModule(MatFormFieldModule),
MockModule(TranslateModule),
ReactiveFormsModule,
],
declarations: [PlaylistInfoComponent, MockPipe(DatePipe)],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: DataService, useClass: ElectronServiceStub },
UntypedFormBuilder,
provideMockStore(),
provideMockActions(actions$),
MockProvider(NgxIndexedDBService),
],
}).compileComponents();
})
);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
FormsModule,
MockModule(MatDialogModule),
MockModule(MatCheckboxModule),
MockModule(MatFormFieldModule),
MockModule(TranslateModule),
MockModule(MatSnackBarModule),
ReactiveFormsModule,
],
declarations: [PlaylistInfoComponent, MockPipe(DatePipe)],
providers: [
MockProvider(MatSnackBar),
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: DataService, useClass: ElectronServiceStub },
UntypedFormBuilder,
provideMockStore(),
provideMockActions(actions$),
MockProvider(NgxIndexedDBService),
MockProvider(TranslateService),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistInfoComponent);
@@ -56,8 +56,12 @@
>
</ng-container>
</ng-container>
<ng-template #xtreamPlaylist
>Xtream/Stalker Playlist
<ng-template #xtreamPlaylist>
@if (item?.macAddress) {
Stalker Portal
} @else {
Xtream Code Playlist
}
<ng-container *ngIf="item.importDate?.length > 3">
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date }}
@@ -1,4 +1,4 @@
<ng-container *ngIf="allPlaylistsLoaded$ | async; else skeletonView">
@if (allPlaylistsLoaded()) {
<mat-form-field
class="full-width"
subscriptSizing="dynamic"
@@ -11,98 +11,105 @@
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
(input)="onSearchQueryUpdate(searchQuery.value)"
/></mat-form-field>
<mat-nav-list
cdkDropList
(cdkDropListDropped)="drop($event, playlists)"
*ngIf="playlists$ | async as playlists"
class="playlists-list"
>
<mat-list-item *ngIf="playlists.length === 0; else playlistsTemplate">
<mat-icon class="favorites-icon" matListItemAvatar>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()">
@defer {
<mat-nav-list
cdkDropList
(cdkDropListDropped)="drop($event, playlists)"
*ngIf="playlists$ | async as playlists"
class="playlists-list"
>
<mat-list-item
*ngIf="playlists.length === 0; else playlistsTemplate"
>
<mat-icon class="favorites-icon" matListItemAvatar
>star</mat-icon
>search</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
</div>
<div matListItemLine class="meta">
{{
'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION'
| translate
}}
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
<mat-divider></mat-divider>
<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)"
></app-playlist-item>
<ng-template #playlistsTemplate>
<mat-list-item (click)="getGlobalFavorites()">
<mat-icon class="favorites-icon" matListItemAvatar
>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>
<mat-divider></mat-divider>
<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
{{ 'SETTINGS.REMOVE_DIALOG.TITLE' | translate }}
</button>
</div>
</ng-template>
</mat-nav-list>
</ng-container>
<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)"
></app-playlist-item>
<ng-template #skeletonView>
<div id="skeleton-container">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader
count="1"
appearance="circle"
></ngx-skeleton-loader>
<div class="text">
<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
{{ 'SETTINGS.REMOVE_DIALOG.TITLE' | translate }}
</button>
</div>
</ng-template>
</mat-nav-list>
} @placeholder {
<div id="skeleton-container">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
></ngx-skeleton-loader>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
></ngx-skeleton-loader>
</div>
<div class="actions">
<ngx-skeleton-loader
count="2"
appearance="circle"
></ngx-skeleton-loader>
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
></ngx-skeleton-loader>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
></ngx-skeleton-loader>
</div>
<div class="actions">
<ngx-skeleton-loader
count="2"
appearance="circle"
></ngx-skeleton-loader>
</div>
</div>
</div>
</div>
</ng-template>
}
}
@@ -58,7 +58,7 @@ export class RecentPlaylistsComponent implements OnDestroy {
)
);
allPlaylistsLoaded$ = this.store.select(selectPlaylistsLoadingFlag);
allPlaylistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag);
@Input() sidebarMode = false;
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, EventEmitter, Output, inject } from '@angular/core';
import {
FormControl,
FormGroup,
@@ -43,6 +43,7 @@ import { addPlaylist } from '../../state/actions';
],
})
export class StalkerPortalImportComponent {
@Output() addClicked = new EventEmitter<void>();
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
form = new FormGroup({
@@ -68,6 +69,7 @@ export class StalkerPortalImportComponent {
this.store.dispatch(
addPlaylist({ playlist: this.form.value as Playlist })
);
this.addClicked.emit();
}
transformPortalUrl(url: string) {
@@ -1,8 +1,7 @@
button {
margin: 0 15px;
text-transform: uppercase;
}
textarea {
height: calc(100vh - 300px) !important;
height: 300px !important;
}
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipe } from 'ng-mocks';
import { TextImportComponent } from './text-import.component';
@@ -10,18 +10,17 @@ describe('TextImportComponent', () => {
let component: TextImportComponent;
let fixture: ComponentFixture<TextImportComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [TextImportComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
],
}).compileComponents();
})
);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [TextImportComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
MockModule(TranslateModule),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TextImportComponent);
@@ -1,10 +1,25 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';
@Component({
standalone: true,
selector: 'app-text-import',
templateUrl: './text-import.component.html',
styleUrls: ['./text-import.component.scss'],
imports: [
MatButtonModule,
MatInputModule,
ReactiveFormsModule,
TranslateModule,
],
})
export class TextImportComponent {
@Output() textAdded = new EventEmitter<string>();
@@ -7,11 +7,9 @@
formControlName="playlistUrl"
/>
</mat-form-field>
<mat-card *ngIf="!isElectron">
<mat-card-content>
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</mat-card-content>
</mat-card>
<p *ngIf="!isElectron">
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</p>
<button
class="add-button"
mat-flat-button
@@ -1,8 +0,0 @@
form {
margin: 15px;
}
button {
margin-top: 10px;
text-transform: uppercase;
}
@@ -4,7 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
import { MockModule, MockPipe } from 'ng-mocks';
import { DataService } from '../../services/data.service';
import { ElectronServiceStub } from '../../services/electron.service.stub';
@@ -14,26 +14,25 @@ describe('UrlUploadComponent', () => {
let component: UrlUploadComponent;
let fixture: ComponentFixture<UrlUploadComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [UrlUploadComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(MatCardModule),
FormsModule,
ReactiveFormsModule,
NoopAnimationsModule,
],
providers: [
{
provide: DataService,
useClass: ElectronServiceStub,
},
],
}).compileComponents();
})
);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [UrlUploadComponent, MockPipe(TranslatePipe)],
imports: [
MockModule(MatInputModule),
MockModule(MatCardModule),
MockModule(TranslateModule),
MockModule(FormsModule),
MockModule(ReactiveFormsModule),
NoopAnimationsModule,
],
providers: [
{
provide: DataService,
useClass: ElectronServiceStub,
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(UrlUploadComponent);
@@ -45,31 +44,27 @@ describe('UrlUploadComponent', () => {
expect(component).toBeTruthy();
});
it(
'submit form with playlist url',
waitForAsync(() => {
jest.spyOn(component.urlAdded, 'emit');
const TEST_URL = 'http://example.org/playlist.m3u';
const submitButton =
fixture.debugElement.nativeElement.querySelector('button');
it('submit form with playlist url', waitForAsync(() => {
jest.spyOn(component.urlAdded, 'emit');
const TEST_URL = 'http://example.org/playlist.m3u';
const submitButton =
fixture.debugElement.nativeElement.querySelector('button');
// test input field validation
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: 'wrong url here' });
fixture.detectChanges();
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: TEST_URL + '8' });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
component.form.setValue({ playlistUrl: TEST_URL });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
// test input field validation
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: 'wrong url here' });
fixture.detectChanges();
expect(submitButton.disabled).toBeTruthy();
component.form.setValue({ playlistUrl: TEST_URL + '8' });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
component.form.setValue({ playlistUrl: TEST_URL });
fixture.detectChanges();
expect(submitButton.disabled).toBeFalsy();
const form = fixture.debugElement.query(By.css('form'));
form.triggerEventHandler('ngSubmit', null);
const form = fixture.debugElement.query(By.css('form'));
form.triggerEventHandler('ngSubmit', null);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(component.urlAdded.emit).toHaveBeenCalledWith(TEST_URL);
})
);
expect(component.urlAdded.emit).toHaveBeenCalledWith(TEST_URL);
}));
});
+25 -11
View File
@@ -1,34 +1,48 @@
import { NgIf } from '@angular/common';
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatInputModule } from '@angular/material/input';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '../../services/data.service';
@Component({
standalone: true,
selector: 'app-url-upload',
templateUrl: './url-upload.component.html',
styleUrls: ['./url-upload.component.scss'],
imports: [
MatButtonModule,
MatCardModule,
MatInputModule,
NgIf,
ReactiveFormsModule,
TranslateModule,
],
})
export class UrlUploadComponent implements OnInit {
/** Emits url string to the parent component on form submit */
@Output() urlAdded: EventEmitter<string> = new EventEmitter();
/** Form group with playlist url */
form: UntypedFormGroup;
form: FormGroup;
/** Is true if app runs in electron-based environment */
isElectron = this.dataService.isElectron;
/**
* Creates an instance of component
* @param fb angular form builder
*/
constructor(private fb: UntypedFormBuilder, private dataService: DataService) {}
constructor(
private fb: FormBuilder,
private dataService: DataService
) {}
ngOnInit(): void {
const urlRegex = '(https?://.*?)';
this.form = this.fb.group({
playlistUrl: [
'',
// eslint-disable-next-line @typescript-eslint/unbound-method
[Validators.required, Validators.pattern(urlRegex)],
],
});
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, EventEmitter, Output, inject } from '@angular/core';
import {
FormControl,
FormGroup,
@@ -43,6 +43,7 @@ import { addPlaylist } from '../../state/actions';
],
})
export class XtreamCodeImportComponent {
@Output() addClicked = new EventEmitter<void>();
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
form = new FormGroup({
@@ -64,5 +65,6 @@ export class XtreamCodeImportComponent {
this.store.dispatch(
addPlaylist({ playlist: this.form.value as Playlist })
);
this.addClicked.emit();
}
}
@@ -1,108 +1,27 @@
<mat-tab-group color="primary" backgroundColor="primary">
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
@if (_channelList) {
<mat-tab-group color="primary" backgroundColor="primary">
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
</div>
</ng-template>
<div class="search-panel">
<input
class="full-width search-bar"
type="search"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchTerm.name"
/>
</div>
</ng-template>
<div class="search-panel">
<input
class="full-width search-bar"
type="search"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchTerm.name"
/>
</div>
<mat-divider></mat-divider>
<mat-nav-list>
<app-channel-list-item
*ngFor="
let channel of _channelList
| filterBy: searchTerm.name : 'name';
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
></app-channel-list-item>
</mat-nav-list>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">category</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.GROUPS' | translate }}
</div>
</ng-template>
<mat-nav-list id="groups-list">
<mat-accordion multi>
<ng-container *ngFor="let groups of groupedChannels | keyvalue">
<mat-expansion-panel>
<mat-expansion-panel-header
*ngIf="groups.value.length > 0"
>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<ng-container
*ngFor="
let channel of groups.value;
index as i;
trackBy: trackByFn
"
>
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel.id"
></app-channel-list-item>
</ng-container>
</ng-template>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
</mat-nav-list>
</mat-tab>
<mat-tab *ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'">
<ng-template mat-tab-label>
<mat-icon class="tab-icon">star</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.FAVORITES' | translate }}
</div>
</ng-template>
<mat-nav-list
cdkDropList
*ngIf="favorites$ | async as favorites"
(cdkDropListDropped)="drop($event, favorites)"
id="favorites-list"
>
<ng-container *ngIf="favorites.length > 0; else noFavorites">
<mat-divider></mat-divider>
<mat-nav-list>
<app-channel-list-item
*ngFor="
let channel of favorites;
*rxFor="
let channel of _channelList
| filterBy: searchTerm.name : 'name';
index as i;
trackBy: trackByFn
"
@@ -113,26 +32,117 @@
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
[showFavoriteButton]="true"
(favoriteToggled)="toggleFavoriteChannel(channel, $event)"
></app-channel-list-item>
</ng-container>
<ng-template #noFavorites>
<mat-list-item
><strong>{{
'CHANNELS.NO_FAVORITES' | translate
}}</strong></mat-list-item
>
<mat-list-item>
{{
'CHANNELS.USE_STAR_TO_FAVORITE' | translate
}}</mat-list-item
>
</mat-nav-list>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">category</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.GROUPS' | translate }}
</div>
</ng-template>
</mat-nav-list>
</mat-tab>
</mat-tab-group>
<mat-nav-list id="groups-list">
<mat-accordion multi>
<ng-container
*ngFor="let groups of groupedChannels | keyvalue"
>
<mat-expansion-panel>
<mat-expansion-panel-header
*ngIf="groups.value.length > 0"
>
{{
groups.key ||
('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<ng-container
*ngFor="
let channel of groups.value;
index as i;
trackBy: trackByFn
"
>
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel.id"
></app-channel-list-item>
</ng-container>
</ng-template>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
</mat-nav-list>
</mat-tab>
<mat-tab *ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'">
<ng-template mat-tab-label>
<mat-icon class="tab-icon">star</mat-icon>
<div class="tab-title">
{{ 'CHANNELS.FAVORITES' | translate }}
</div>
</ng-template>
<mat-nav-list
cdkDropList
*ngIf="favorites$ | async as favorites"
(cdkDropListDropped)="drop($event, favorites)"
id="favorites-list"
>
<ng-container *ngIf="favorites.length > 0; else noFavorites">
<app-channel-list-item
*ngFor="
let channel of favorites;
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
[showFavoriteButton]="true"
(favoriteToggled)="
toggleFavoriteChannel(channel, $event)
"
></app-channel-list-item>
</ng-container>
<ng-template #noFavorites>
<mat-list-item
><strong>{{
'CHANNELS.NO_FAVORITES' | translate
}}</strong></mat-list-item
>
<mat-list-item>
{{
'CHANNELS.USE_STAR_TO_FAVORITE' | translate
}}</mat-list-item
>
</ng-template>
</mat-nav-list>
</mat-tab>
</mat-tab-group>
} @else {
No channels there
}
@@ -13,7 +13,7 @@ import { RouterTestingModule } from '@angular/router/testing';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { MockModule, MockPipes, MockProviders } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { Observable } from 'rxjs';
@@ -38,7 +38,7 @@ describe('ChannelListContainerComponent', () => {
TestBed.configureTestingModule({
declarations: [
ChannelListContainerComponent,
MockPipes(TranslatePipe, FilterPipe),
MockPipes(FilterPipe),
],
providers: [
{ provide: MatSnackBar, useClass: MatSnackBarStub },
@@ -56,6 +56,7 @@ describe('ChannelListContainerComponent', () => {
MockModule(MatTabsModule),
MockModule(MatTooltipModule),
MockModule(MatExpansionModule),
MockModule(TranslateModule),
FormsModule,
RouterTestingModule,
],
@@ -1,4 +1,5 @@
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { CommonModule, TitleCasePipe } from '@angular/common';
import {
Component,
ElementRef,
@@ -6,21 +7,45 @@ import {
Input,
ViewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RxFor } from '@rx-angular/template/for';
import * as _ from 'lodash';
import { map, skipWhile } from 'rxjs';
import { Channel } from '../../../../../shared/channel.interface';
import { FilterPipe } from '../../../shared/pipes/filter.pipe';
import * as PlaylistActions from '../../../state/actions';
import {
selectActivePlaylistId,
selectFavorites,
} from '../../../state/selectors';
import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component';
@Component({
standalone: true,
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
styleUrls: ['./channel-list-container.component.scss'],
imports: [
CommonModule,
ChannelListItemComponent,
MatExpansionModule,
MatDividerModule,
MatListModule,
MatIconModule,
MatTabsModule,
TranslateModule,
TitleCasePipe,
FilterPipe,
FormsModule,
RxFor,
],
})
export class ChannelListContainerComponent {
/**
@@ -70,17 +95,18 @@ export class ChannelListContainerComponent {
);
/** List with favorites */
favorites$ = this.store
.select(selectFavorites)
.pipe(
map((favoriteChannelIds) =>
favorites$ = this.store.select(selectFavorites).pipe(
map(
(
favoriteChannelIds // TODO: move to selector
) =>
favoriteChannelIds.map((favoriteChannelId) =>
this.channelList.find(
(channel) => channel.id === favoriteChannelId
)
)
)
);
)
);
constructor(
private readonly store: Store,
@@ -117,8 +143,8 @@ export class ChannelListContainerComponent {
* @param index index of the channel item
* @param channel channel object
*/
trackByFn(index: number, channel: Channel): string {
return channel.id;
trackByFn(_: number, channel: Channel): string {
return channel?.id;
}
drop(event: CdkDragDrop<Channel[]>, favorites: Channel[]) {
@@ -129,4 +155,8 @@ export class ChannelListContainerComponent {
})
);
}
ngOnDestroy() {
this.store.dispatch(PlaylistActions.setChannels({ channels: [] }));
}
}
@@ -1,6 +1,15 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
@Component({
standalone: true,
selector: 'app-channel-list-item',
styleUrls: ['./channel-list-item.component.scss'],
template: `<mat-list-item
@@ -40,6 +49,16 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
</button>
</mat-list-item>
<mat-divider></mat-divider>`,
imports: [
CommonModule,
DragDropModule,
MatButtonModule,
MatDividerModule,
MatIconModule,
MatListModule,
MatTooltipModule,
TranslateModule,
],
})
export class ChannelListItemComponent {
@Input() isDraggable = false;
@@ -2,6 +2,8 @@ import { SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { DataService } from '../../../services/data.service';
import { ElectronServiceStub } from '../../../services/electron.service.stub';
import { HtmlVideoPlayerComponent } from './html-video-player.component';
describe('HtmlVideoPlayerComponent', () => {
@@ -19,6 +21,9 @@ describe('HtmlVideoPlayerComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [HtmlVideoPlayerComponent, MockPipe(TranslatePipe)],
providers: [
{ provide: DataService, useClass: ElectronServiceStub },
],
}).compileComponents();
}));
@@ -17,7 +17,7 @@
<div class="channels-count">
{{
sidebarView === 'CHANNELS'
? channels.length +
? channels?.length +
' ' +
('HOME.PLAYLISTS.CHANNELS' | translate)
: ('HOME.PLAYLISTS.MY_PLAYLISTS_SUBTITLE' | translate)
@@ -38,16 +38,13 @@
</div>
</div>
<mat-divider></mat-divider>
<app-channel-list-container
*ngIf="sidebarView === 'CHANNELS'; else playlistsTemplate"
[channelList]="channels"
>
</app-channel-list-container>
<ng-template #playlistsTemplate>
@if (sidebarView === 'CHANNELS') {
<app-channel-list-container [channelList]="channels" />
} @else {
<app-recent-playlists
class="recent-playlists"
[sidebarMode]="true"
[class.electron]="dataService.isElectron"
(playlistClicked)="selectPlaylist()"
></app-recent-playlists>
</ng-template>
/>
}
@@ -1,16 +1,13 @@
<mat-drawer-container class="main-container">
<!-- sidebar content -->
<mat-drawer #drawer mode="side" opened disableClose>
<app-sidebar
*ngIf="channels$ | async as channels; else noChannels"
[channels]="channels"
/>
<ng-template #noChannels
>No channels <br />
<button mat-button color="secondary" (click)="navigateHome()">
Go back
</button></ng-template
>
@defer {
<app-sidebar [channels]="channels$ | async" />
} @loading {
<p>Loading...</p>
} @placeholder {
<p>Loading...</p>
}
</mat-drawer>
<mat-drawer-content>
<ng-container *ngIf="activeChannel$ | async as activeChannel">
+2 -2
View File
@@ -22,6 +22,8 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
@NgModule({
imports: [
AudioPlayerComponent,
ChannelListContainerComponent,
ChannelListItemComponent,
CommonModule,
HtmlVideoPlayerComponent,
OverlayModule,
@@ -30,8 +32,6 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }];
VjsPlayerComponent,
],
declarations: [
ChannelListContainerComponent,
ChannelListItemComponent,
EpgItemDescriptionComponent,
EpgListComponent,
EpgListItemComponent,
+24 -4
View File
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { parse } from 'iptv-playlist-parser';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { combineLatest, map, switchMap } from 'rxjs';
@@ -23,7 +25,11 @@ import { PlaylistMeta } from '../shared/playlist-meta.type';
providedIn: 'root',
})
export class PlaylistsService {
constructor(private dbService: NgxIndexedDBService) {}
constructor(
private dbService: NgxIndexedDBService,
private snackBar: MatSnackBar,
private translateService: TranslateService
) {}
getAllPlaylists() {
return this.dbService.getAll<Playlist>(DbStores.Playlists).pipe(
@@ -198,8 +204,22 @@ export class PlaylistsService {
title: string,
path?: string
) {
const parsedPlaylist = parse(playlist);
return createPlaylistObject(title, parsedPlaylist, path, uploadType);
try {
const parsedPlaylist = parse(playlist);
return createPlaylistObject(
title,
parsedPlaylist,
path,
uploadType
);
} catch (error) {
this.snackBar.open(
this.translateService.instant('HOME.PARSING_ERROR'),
null,
{ duration: 2000 }
); // TODO: translate
throw new Error(`Parsing failed, not a valid playlist: ${error}`);
}
}
getPlaylistWithGlobalFavorites() {
@@ -213,7 +233,7 @@ export class PlaylistsService {
}
addManyPlaylists(playlists: Playlist[]) {
return this.dbService.bulkAdd(DbStores.Playlists, playlists as any); // TODO: update ngx-indexed-db
return this.dbService.bulkAdd(DbStores.Playlists, playlists as any);
}
getPlaylistsForAutoUpdate() {
+2 -2
View File
@@ -54,9 +54,9 @@ describe('Service: Settings', () => {
spyOnRemove = jest.spyOn(document.body.classList, 'remove');
});
/* afterEach(() => {
afterEach(() => {
jest.clearAllMocks();
}); */
});
it('should switch to the dark theme', inject(
[SettingsService],
+40 -20
View File
@@ -4,12 +4,21 @@ import {
FormArray,
FormBuilder,
FormControl,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs';
import * as semver from 'semver';
import {
@@ -22,8 +31,8 @@ import { DataService } from '../services/data.service';
import { DialogService } from '../services/dialog.service';
import { EpgService } from '../services/epg.service';
import { PlaylistsService } from '../services/playlists.service';
import { HeaderComponent } from '../shared/components/header/header.component';
import { STORE_KEY } from '../shared/enums/store-keys.enum';
import { SharedModule } from '../shared/shared.module';
import * as PlaylistActions from '../state/actions';
import { selectIsEpgAvailable } from '../state/selectors';
import { SettingsService } from './../services/settings.service';
@@ -35,7 +44,20 @@ import { Theme } from './theme.enum';
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
standalone: true,
imports: [CommonModule, SharedModule],
imports: [
CommonModule,
FormsModule,
HeaderComponent,
MatButtonModule,
MatCheckboxModule,
MatDividerModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
],
})
export class SettingsComponent implements OnInit {
/** List with available languages as enum */
@@ -128,23 +150,21 @@ export class SettingsComponent implements OnInit {
.getValueFromLocalStorage(STORE_KEY.Settings)
.subscribe((settings: Settings) => {
if (settings) {
this.settingsForm.setValue({
player: settings.player
? settings.player
: VideoPlayer.VideoJs,
...(this.isElectron ? { epgUrl: [] } : {}),
language: settings.language
? settings.language
: Language.ENGLISH,
showCaptions: settings.showCaptions
? settings.showCaptions
: false,
theme: settings.theme
? settings.theme
: Theme.LightTheme,
mpvPlayerPath: settings.mpvPlayerPath,
vlcPlayerPath: settings.vlcPlayerPath,
});
try {
this.settingsForm.setValue({
player: settings.player
? settings.player
: VideoPlayer.VideoJs,
...(this.isElectron ? { epgUrl: [] } : {}),
language: settings.language ?? Language.ENGLISH,
showCaptions: settings.showCaptions ?? false,
theme: settings.theme ?? Theme.LightTheme,
mpvPlayerPath: settings.mpvPlayerPath ?? '',
vlcPlayerPath: settings.vlcPlayerPath ?? '',
});
} catch (error) {
throw new Error(error);
}
if (this.isElectron) {
this.setEpgUrls(settings.epgUrl);
@@ -0,0 +1,17 @@
<h2 mat-dialog-title>Add Playlist: {{ playlistType }}</h2>
<mat-dialog-content class="mat-typography">
@if (playlistType === 'file') {
<app-file-upload
(fileRejected)="rejectFile($event)"
(fileSelected)="handlePlaylist($event)"
/>
} @else if (playlistType === 'url') {
<app-url-upload (urlAdded)="sendPlaylistsUrl($event)" />
} @else if (playlistType === 'text') {
<app-text-import (textAdded)="uploadAsText($event)" />
} @else if (playlistType === 'stalker') {
<app-stalker-portal-import (addClicked)="closeDialog()" />
} @else if (playlistType === 'xtream') {
<app-xtream-code-import (addClicked)="closeDialog()" />
}
</mat-dialog-content>
@@ -0,0 +1,113 @@
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PLAYLIST_PARSE_BY_URL } from '../../../../../shared/ipc-commands';
import { getFilenameFromUrl } from '../../../../../shared/playlist.utils';
import { FileUploadComponent } from '../../../home/file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../../../home/stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../../../home/text-import/text-import.component';
import { UrlUploadComponent } from '../../../home/url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../../../home/xtream-code-import/xtream-code-import.component';
import { DataService } from '../../../services/data.service';
import { parsePlaylist } from '../../../state/actions';
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
@Component({
standalone: true,
imports: [
MatButtonModule,
MatDialogModule,
TranslateModule,
FileUploadComponent,
XtreamCodeImportComponent,
StalkerPortalImportComponent,
TextImportComponent,
UrlUploadComponent,
],
selector: 'app-add-playlist',
templateUrl: './add-playlist-dialog.component.html',
})
export class AddPlaylistDialogComponent {
playlistType!: PlaylistType;
constructor(
@Inject(MAT_DIALOG_DATA) data: { type: PlaylistType },
private dataService: DataService,
private dialogRef: MatDialogRef<AddPlaylistDialogComponent>,
private store: Store,
private snackBar: MatSnackBar,
private translateService: TranslateService
) {
this.playlistType = data.type;
}
/**
* Parse and store uploaded playlist
* @param payload
*/
handlePlaylist(payload: { uploadEvent: Event; file: File }): void {
// this.isLoading = true;
const playlist = (payload.uploadEvent.target as FileReader)
.result as string;
this.store.dispatch(
parsePlaylist({
uploadType: 'FILE',
playlist,
title: payload.file.name,
path: payload.file.path,
})
);
this.closeDialog();
}
rejectFile(filename: string): void {
this.snackBar.open(
this.translateService.instant('HOME.FILE_UPLOAD.REJECTED', {
filename,
})
);
// this.isLoading = false;
}
/**
* Sends url of the playlist to the renderer process
* @param playlistUrl url of the added playlist
*/
sendPlaylistsUrl(playlistUrl: string): void {
// this.isLoading = true;
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
title: getFilenameFromUrl(playlistUrl),
url: playlistUrl,
});
this.closeDialog();
}
/**
* Sends IPC event to the renderer process to parse playlist
* @param text playlist as string
*/
uploadAsText(playlist: string): void {
// this.isLoading = true;
this.store.dispatch(
parsePlaylist({
uploadType: 'TEXT',
playlist,
title: this.translateService.instant('HOME.IMPORTED_AS_TEXT'),
})
);
this.closeDialog();
}
closeDialog() {
this.dialogRef.close();
}
}
@@ -6,14 +6,82 @@
<div class="subtitle">{{ subtitle }}</div>
</div>
<div class="menu">
<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>
<!-- <button
mat-icon-button
[matTooltip]="'HOME.FILTER_BY_NAME' | translate"
data-test-id="filter-playlist-by-name"
>
<mat-icon>search</mat-icon>
</button>
<button
mat-icon-button
[matTooltip]="'HOME.FILTER_BY_TYPE' | translate"
data-test-id="filter-playlist-by-type"
>
<mat-icon>filter_list</mat-icon>
</button> -->
<button
*ngIf="isElectron; else pwaMenu"
mat-icon-button
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
data-test-id="open-settings"
>
<mat-icon>settings</mat-icon>
</button>
<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>
<ng-template #pwaMenu>
@@ -32,7 +32,15 @@
.menu {
place-content: center;
align-self: center;
min-width: 50px;
max-width: 50px;
display: flex;
}
.add-playlist-btn {
background-color: #3f51b5;
}
@media (max-width: 500px) {
.main {
display: none;
}
}
@@ -1,15 +1,40 @@
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { NgxWhatsNewModule } from 'ngx-whats-new';
import { HomeComponent } from '../../../home/home.component';
import { DataService } from '../../../services/data.service';
import { WhatsNewService } from '../../../services/whats-new.service';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import {
AddPlaylistDialogComponent,
PlaylistType,
} from '../add-playlist/add-playlist-dialog.component';
@Component({
standalone: true,
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
imports: [
AsyncPipe,
NgIf,
NgFor,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatDividerModule,
MatTooltipModule,
NgxWhatsNewModule,
TranslateModule,
],
})
export class HeaderComponent implements OnInit {
/** Title of the header */
@@ -82,4 +107,14 @@ export class HeaderComponent implements OnInit {
data: this.dataService.getAppVersion(),
});
}
opedAddPlaylistDialog(type: PlaylistType) {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type },
}
);
}
}
-3
View File
@@ -8,13 +8,11 @@ import { NgxWhatsNewModule } from 'ngx-whats-new';
import { PlaylistItemComponent } from '../home/recent-playlists/playlist-item/playlist-item.component';
import { RecentPlaylistsComponent } from '../home/recent-playlists/recent-playlists.component';
import { MaterialModule } from '../material.module';
import { HeaderComponent } from './components/';
import { FilterPipe } from './pipes/filter.pipe';
import { MomentDatePipe } from './pipes/moment-date.pipe';
@NgModule({
declarations: [
HeaderComponent,
MomentDatePipe,
RecentPlaylistsComponent,
PlaylistItemComponent,
@@ -37,7 +35,6 @@ import { MomentDatePipe } from './pipes/moment-date.pipe';
DragDropModule,
FilterPipe,
FormsModule,
HeaderComponent,
MaterialModule,
NgxWhatsNewModule,
MomentDatePipe,
+2 -1
View File
@@ -136,7 +136,8 @@ export class PlaylistEffects {
} else if (
settings &&
Object.keys(settings).length > 0 &&
settings.player === VideoPlayer.VLC
settings.player === VideoPlayer.VLC &&
channel.radio !== 'true'
)
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: channel.url,
@@ -313,6 +313,8 @@ export class XtreamMainContainerComponent implements OnInit {
const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`;
if (player === VideoPlayer.MPV) {
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl });
} else if (player === VideoPlayer.VLC) {
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: streamUrl });
} else {
this.dialog.open(PlayerDialogComponent, {
data: { streamUrl, player, title: episode.title },
+9 -5
View File
@@ -2,18 +2,18 @@
"CLOSE": "Close",
"HOME": {
"TITLE": "IPTVnator",
"SUBTITLE": "Please select a playlist from the list or add a new playlist via file upload or via url",
"SUBTITLE": "Please select a playlist from the list or add a new playlist",
"TABS": {
"RECENTLY_ADDED": "Recently added playlists",
"FILE_UPLOAD": "Add via file upload",
"URL_UPLOAD": "Add via URL",
"TEXT_IMPORT": "Import as text",
"TEXT_IMPORT": "Import from text",
"XTREME_IMPORT": "Add Xtreme Code",
"STALKER_PORTAL_IMPORT": "Add Stalker Portal"
},
"PLAYLISTS": {
"NO_PLAYLISTS": "No playlists were added",
"ADD_FIRST": "Please add your first playlist from the other tab",
"ADD_FIRST": "Please add your first playlist",
"ADDED_VIA_URL": "Added via URL upload. URL:",
"ADDED_VIA_FILE": "Added via file upload",
"XTREAM_PLAYLIST": "Xtream playlist",
@@ -65,7 +65,7 @@
"URL_UPLOAD": {
"PLAYLIST_URL": "Playlist URL (m3u, m3u8)",
"ADD_PLAYLIST": "Add playlist",
"CORS_NOTE": "Note: In order to avoid CORS issues at this point the application uses a public CORS Proxy service. If you want to upload a playlist with sensitive data, it is better to import it as a file in the adjacent tab."
"CORS_NOTE": "Note: In order to avoid CORS issues at this point the application uses a public CORS Proxy service. If you want to upload a playlist with sensitive data, it's more secure to import it from a file."
},
"TEXT_IMPORT": {
"LABEL": "Insert m3u(8) playlist as text",
@@ -86,7 +86,11 @@
"SERVER_URL": "Server URL",
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com/c or https://example.com/stalker_portal/c)",
"ADD": "Add"
}
},
"FILTER_BY_NAME": "Filter by name",
"FILTER_BY_TYPE": "Filter by type",
"IMPORTED_AS_TEXT": "Imported as text",
"PARSING_ERROR": "Error: Parsing failed, not a valid playlist:"
},
"SETTINGS": {
"TITLE": "Settings / IPTVnator",
+1 -1
View File
@@ -87,7 +87,7 @@ textarea,
}
.recent-playlists {
height: calc(100vh - 152px - 28px) !important;
height: calc(100vh - 106px - 28px) !important;
}
#groups-list,