mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
Merge pull request #334 from 4gray/feat/angular-17-update
Update to Angular 17 and other minor changes
This commit is contained in:
52 files changed
+9302
-6947
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -5,5 +5,5 @@
|
||||
|
||||
npx stylelint "**/*.scss"
|
||||
npx --no-install commitlint --edit
|
||||
npm test -- --coverage
|
||||
npm test
|
||||
npx eslint src/**/*.ts
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)'],
|
||||
};
|
||||
Generated
+8440
-6242
File diff suppressed because it is too large.
Load diff
+51
-48
@@ -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,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>
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.recent-playlists {
|
||||
height: calc(100vh - 152px);
|
||||
height: calc(100vh - 106px);
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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);
|
||||
}));
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+131
-121
@@ -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
|
||||
}
|
||||
+3
-2
@@ -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,
|
||||
],
|
||||
|
||||
+39
-9
@@ -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: [] }));
|
||||
}
|
||||
}
|
||||
+19
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -87,7 +87,7 @@ textarea,
|
||||
}
|
||||
|
||||
.recent-playlists {
|
||||
height: calc(100vh - 152px - 28px) !important;
|
||||
height: calc(100vh - 106px - 28px) !important;
|
||||
}
|
||||
|
||||
#groups-list,
|
||||
|
||||
Reference in new issue
Block a user