diff --git a/docker/Dockerfile b/docker/Dockerfile index c24a881e2..fd99b15c1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,7 @@ # Stage 1 - build environment FROM node:18-alpine AS build -RUN apk add --no-cache python3 make g++ +RUN apk add --no-cache python3 make g++ git # Create app directory WORKDIR /usr/src/app @@ -10,10 +10,11 @@ WORKDIR /usr/src/app #RUN chown node:node ./ #USER node +COPY .npmrc ./ COPY package*.json ./ # Install app dependencies -RUN npm ci && npm cache clean --force +RUN npm ci # Copy all required files COPY . . diff --git a/electron/api.ts b/electron/api.ts index 2b56d57cb..0843c3009 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -25,11 +25,15 @@ import { MIGRATE_PLAYLISTS_RESPONSE, OPEN_FILE, OPEN_MPV_PLAYER, + OPEN_VLC_PLAYER, PLAYLIST_PARSE_BY_URL, PLAYLIST_PARSE_RESPONSE, PLAYLIST_UPDATE, PLAYLIST_UPDATE_RESPONSE, SET_MPV_PLAYER_PATH, + SET_VLC_PLAYER_PATH, + STALKER_REQUEST, + STALKER_RESPONSE, XTREAM_REQUEST, XTREAM_RESPONSE, } from '../shared/ipc-commands'; @@ -39,6 +43,8 @@ import { ParsedPlaylist } from '../src/typings.d'; const fs = require('fs'); const https = require('https'); +const child_process = require('child_process'); +const path = require('path'); const mpvAPI = require('node-mpv'); @@ -63,6 +69,7 @@ const agent = new https.Agent({ }); const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH'; +const VLC_PLAYER_PATH = 'VLC_PLAYER_PATH'; export class Api { /** Instance of the main application window */ @@ -242,8 +249,11 @@ export class Api { } catch (error) { console.log(error); event.sender.send(ERROR, { - message: - 'Error: Something went wrong. Make sure that mpv player is installed on your system.', + message: `Error: ${ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + error?.verbose ?? + 'Something went wrong. Make sure that mpv player is installed on your system.' + } `, }); } }) @@ -254,6 +264,24 @@ export class Api { // recreate mpv player instance with new binary path if it was changed if (store.get(MPV_PLAYER_PATH, mpvPlayerPath) !== mpvPlayerPath) this.mpv = this.createMpvInstance(); + }) + .on(OPEN_VLC_PLAYER, (event, { url }) => { + const proc = child_process.spawn( + this.getVlcPath(), + [`"${url as string}"`], + { + shell: true, + } + ); + + proc.on('exit', (code) => { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + console.log(`VLC exited with code ${code}`); + }); + }) + .on(SET_VLC_PLAYER_PATH, (_event, vlcPlayerPath) => { + console.log('... setting vlc player path', vlcPlayerPath); + store.set(VLC_PLAYER_PATH, vlcPlayerPath); }); // listeners for EPG events @@ -295,17 +323,52 @@ export class Api { this.workerWindow.webContents.send(EPG_FORCE_FETCH, arg) ); - ipcMain.on( - XTREAM_REQUEST, - (event, arg: { url: string; params: Record }) => { - const xtreamApiPath = '/player_api.php'; + ipcMain + .on( + XTREAM_REQUEST, + ( + event, + arg: { url: string; params: Record } + ) => { + const xtreamApiPath = '/player_api.php'; + axios + .get(arg.url + xtreamApiPath, { + params: arg.params ?? {}, + }) + .then((result) => { + event.sender.send(XTREAM_RESPONSE, { + payload: result.data, + action: arg.params.action, + }); + }) + .catch((err) => { + event.sender.send(ERROR, { + message: + err.response?.statusText ?? + 'Error: not found', + status: err.response?.status ?? 404, + }); + }); + } + ) + .on(STALKER_REQUEST, (event, arg: any) => { axios - .get(arg.url + xtreamApiPath, { + .get(arg.url, { params: arg.params ?? {}, + headers: { + Cookie: `mac=${arg.macAddress as string}`, + ...(arg.params.token + ? { + Authorization: `Bearer ${ + arg.params.token as string + }`, + } + : {}), + }, }) .then((result) => { - event.sender.send(XTREAM_RESPONSE, { + event.sender.send(STALKER_RESPONSE, { payload: result.data, action: arg.params.action, }); @@ -317,8 +380,7 @@ export class Api { status: err.response?.status ?? 404, }); }); - } - ); + }); } createMpvInstance() { @@ -503,6 +565,31 @@ export class Api { return parse(m3uString); } + getDefaultVlcPath() { + if (process.platform === 'win32') { + return path.join( + 'C:', + 'Program Files (x86)', + 'VideoLAN', + 'VLC', + 'vlc.exe' + ); + } else if (process.platform === 'linux') { + return '/usr/bin/vlc'; + } else if (process.platform === 'darwin') { + return '/Applications/VLC.app/Contents/MacOS/VLC'; + } + } + + getVlcPath() { + const customVlcPath = this.store.get(VLC_PLAYER_PATH); + if (customVlcPath) { + return customVlcPath; + } else { + return this.getDefaultVlcPath(); + } + } + /** @deprecated - used only for migration */ getAllPlaylists() { return db diff --git a/electron/main.ts b/electron/main.ts index dc931d8c2..3c0303d6d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -64,7 +64,7 @@ function createWindow(): BrowserWindow { } win.on('close', () => { - store.set(WINDOW_BOUNDS, win?.getNormalBounds()); + if (win) store.set(WINDOW_BOUNDS, win.getNormalBounds()); }); // Emitted when the window is closed. @@ -144,7 +144,7 @@ try { }); app.on('before-quit', () => { - store.set(WINDOW_BOUNDS, win?.getNormalBounds()); + if (win) store.set(WINDOW_BOUNDS, win.getNormalBounds()); }); if (serve && process.platform === 'darwin') { diff --git a/package-lock.json b/package-lock.json index 317fec713..0568c85c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "iptvnator", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "iptvnator", - "version": "0.13.0", + "version": "0.14.0", "hasInstallScript": true, "dependencies": { "@angular/animations": "16.1.5", @@ -30,7 +30,7 @@ "electron-store": "8.1.0", "epg-parser": "0.1.6", "hls.js": "1.2.7", - "iptv-playlist-parser": "0.8.0", + "iptv-playlist-parser": "github:4gray/iptv-playlist-parser", "lodash": "4.17.21", "moment": "2.29.4", "nedb-promises": "6.0.3", @@ -69,7 +69,7 @@ "@ngrx/eslint-plugin": "^16.1.0", "@ngx-translate/core": "14.0.0", "@ngx-translate/http-loader": "7.0.0", - "@playwright/test": "1.31.1", + "@playwright/test": "1.37.1", "@semantic-release/changelog": "6.0.1", "@semantic-release/git": "10.0.1", "@semantic-release/npm": "9.0.0", @@ -92,9 +92,9 @@ "ng-mocks": "14.10.0", "node-polyfill-webpack-plugin": "1.1.4", "npm-run-all": "4.1.5", - "playwright": "1.31.1", - "playwright-core": "1.31.1", - "prettier": "2.8.8", + "playwright": "1.37.1", + "playwright-core": "1.37.1", + "prettier": "3.0.0", "semantic-release": "19.0.3", "stylelint": "14.2.0", "stylelint-config-standard": "24.0.0", @@ -6742,19 +6742,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.31.1.tgz", - "integrity": "sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==", + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz", + "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==", "dev": true, "dependencies": { "@types/node": "*", - "playwright-core": "1.31.1" + "playwright-core": "1.37.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=16" }, "optionalDependencies": { "fsevents": "2.3.2" @@ -15746,9 +15746,13 @@ } }, "node_modules/iptv-playlist-parser": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/iptv-playlist-parser/-/iptv-playlist-parser-0.8.0.tgz", - "integrity": "sha512-83+6Fl248rxm6tyzgPi2rqHQU1IBZnZvbWXyGAcBxveitckc+sh6b4+aDqQa+u2IJP2HVZilRd2C229kqJtLuQ==" + "version": "0.12.2", + "resolved": "git+ssh://git@github.com/4gray/iptv-playlist-parser.git#2b48764180edf25821dae3a4b1c9a6a7d0a3bf84", + "license": "MIT", + "dependencies": { + "is-valid-path": "^0.1.1", + "validator": "^13.7.0" + } }, "node_modules/is-arguments": { "version": "1.1.1", @@ -15959,6 +15963,36 @@ "node": ">=8" } }, + "node_modules/is-invalid-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-0.1.0.tgz", + "integrity": "sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==", + "dependencies": { + "is-glob": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-invalid-path/node_modules/is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-invalid-path/node_modules/is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", + "dependencies": { + "is-extglob": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -16212,6 +16246,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-valid-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz", + "integrity": "sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==", + "dependencies": { + "is-invalid-path": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -24655,31 +24700,31 @@ } }, "node_modules/playwright": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.31.1.tgz", - "integrity": "sha512-zKJabsIA2rvOwJ12lGTqWv4HVJzlfw2JtUvO4hAr7J8UXQZ1qEPpX20E1vcz/9fotnTkwgqp3CVdIBwptBN3Fg==", + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.37.1.tgz", + "integrity": "sha512-bgUXRrQKhT48zHdxDYQTpf//0xDfDd5hLeEhjuSw8rXEGoT9YeElpfvs/izonTNY21IQZ7d3s22jLxYaAnubbQ==", "dev": true, "hasInstallScript": true, "dependencies": { - "playwright-core": "1.31.1" + "playwright-core": "1.37.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=16" } }, "node_modules/playwright-core": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.1.tgz", - "integrity": "sha512-JTyX4kV3/LXsvpHkLzL2I36aCdml4zeE35x+G5aPc4bkLsiRiQshU5lWeVpHFAuC8xAcbI6FDcw/8z3q2xtJSQ==", + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz", + "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==", "dev": true, "bin": { - "playwright": "cli.js" + "playwright-core": "cli.js" }, "engines": { - "node": ">=14" + "node": ">=16" } }, "node_modules/plist": { @@ -24898,15 +24943,15 @@ } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0.tgz", + "integrity": "sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==", "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -29130,6 +29175,14 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/validator": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -34972,14 +35025,14 @@ "optional": true }, "@playwright/test": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.31.1.tgz", - "integrity": "sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==", + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.37.1.tgz", + "integrity": "sha512-bq9zTli3vWJo8S3LwB91U0qDNQDpEXnw7knhxLM0nwDvexQAwx9tO8iKDZSqqneVq+URd/WIoz+BALMqUTgdSg==", "dev": true, "requires": { "@types/node": "*", "fsevents": "2.3.2", - "playwright-core": "1.31.1" + "playwright-core": "1.37.1" } }, "@schematics/angular": { @@ -41965,9 +42018,12 @@ "dev": true }, "iptv-playlist-parser": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/iptv-playlist-parser/-/iptv-playlist-parser-0.8.0.tgz", - "integrity": "sha512-83+6Fl248rxm6tyzgPi2rqHQU1IBZnZvbWXyGAcBxveitckc+sh6b4+aDqQa+u2IJP2HVZilRd2C229kqJtLuQ==" + "version": "git+ssh://git@github.com/4gray/iptv-playlist-parser.git#2b48764180edf25821dae3a4b1c9a6a7d0a3bf84", + "from": "iptv-playlist-parser@github:4gray/iptv-playlist-parser", + "requires": { + "is-valid-path": "^0.1.1", + "validator": "^13.7.0" + } }, "is-arguments": { "version": "1.1.1", @@ -42109,6 +42165,29 @@ "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true }, + "is-invalid-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-0.1.0.tgz", + "integrity": "sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==", + "requires": { + "is-glob": "^2.0.0" + }, + "dependencies": { + "is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==" + }, + "is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", + "requires": { + "is-extglob": "^1.0.0" + } + } + } + }, "is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -42275,6 +42354,14 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, + "is-valid-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz", + "integrity": "sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==", + "requires": { + "is-invalid-path": "^0.1.0" + } + }, "is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -48508,18 +48595,18 @@ } }, "playwright": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.31.1.tgz", - "integrity": "sha512-zKJabsIA2rvOwJ12lGTqWv4HVJzlfw2JtUvO4hAr7J8UXQZ1qEPpX20E1vcz/9fotnTkwgqp3CVdIBwptBN3Fg==", + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.37.1.tgz", + "integrity": "sha512-bgUXRrQKhT48zHdxDYQTpf//0xDfDd5hLeEhjuSw8rXEGoT9YeElpfvs/izonTNY21IQZ7d3s22jLxYaAnubbQ==", "dev": true, "requires": { - "playwright-core": "1.31.1" + "playwright-core": "1.37.1" } }, "playwright-core": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.1.tgz", - "integrity": "sha512-JTyX4kV3/LXsvpHkLzL2I36aCdml4zeE35x+G5aPc4bkLsiRiQshU5lWeVpHFAuC8xAcbI6FDcw/8z3q2xtJSQ==", + "version": "1.37.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.1.tgz", + "integrity": "sha512-17EuQxlSIYCmEMwzMqusJ2ztDgJePjrbttaefgdsiqeLWidjYz9BxXaTaZWxH1J95SHGk6tjE+dwgWILJoUZfA==", "dev": true }, "plist": { @@ -48660,9 +48747,9 @@ "dev": true }, "prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0.tgz", + "integrity": "sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==", "dev": true }, "pretty-bytes": { @@ -51901,6 +51988,11 @@ "builtins": "^5.0.0" } }, + "validator": { + "version": "13.9.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", + "integrity": "sha512-B+dGG8U3fdtM0/aNK4/X8CXq/EcxU2WPrPEkJGslb47qyHsxmbggTWK0yEA4qnYVNF+nxNlN88o14hIcPmSIEA==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index c81dce98c..f41bb539c 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test:watch": "jest --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 e2e/playwright.config.ts e2e/ /", + "e2e:debug": "npm run build:electron && PWDEBUG=1 npx playwright test -c playwright.config.ts e2e/ /", "e2e:html-report": "npm run build:electron && npm run e2e -- --reporter=html", "e2e:show-report": "npx playwright show-report", "version": "conventional-changelog -i CHANGELOG.md -p angular -s -r 0 && git add CHANGELOG.md", @@ -72,7 +72,7 @@ "electron-store": "8.1.0", "epg-parser": "0.1.6", "hls.js": "1.2.7", - "iptv-playlist-parser": "0.8.0", + "iptv-playlist-parser": "github:4gray/iptv-playlist-parser", "lodash": "4.17.21", "moment": "2.29.4", "nedb-promises": "6.0.3", @@ -111,7 +111,7 @@ "@ngrx/eslint-plugin": "^16.1.0", "@ngx-translate/core": "14.0.0", "@ngx-translate/http-loader": "7.0.0", - "@playwright/test": "1.31.1", + "@playwright/test": "1.37.1", "@semantic-release/changelog": "6.0.1", "@semantic-release/git": "10.0.1", "@semantic-release/npm": "9.0.0", @@ -134,9 +134,9 @@ "ng-mocks": "14.10.0", "node-polyfill-webpack-plugin": "1.1.4", "npm-run-all": "4.1.5", - "playwright": "1.31.1", - "playwright-core": "1.31.1", - "prettier": "2.8.8", + "playwright": "1.37.1", + "playwright-core": "1.37.1", + "prettier": "3.0.0", "semantic-release": "19.0.3", "stylelint": "14.2.0", "stylelint-config-standard": "24.0.0", @@ -148,7 +148,7 @@ "wait-on": "6.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "browserslist": [ "chrome 91" diff --git a/shared/channel.interface.ts b/shared/channel.interface.ts index f907e59c6..67d18d629 100644 --- a/shared/channel.interface.ts +++ b/shared/channel.interface.ts @@ -12,8 +12,6 @@ export interface Channel { tvg: { id: string; name: string; - language: string; - country: string; url: string; logo: string; rec: string; @@ -29,4 +27,5 @@ export interface Channel { referrer: string; 'user-agent': string; }; + radio: string; } diff --git a/shared/ipc-commands.ts b/shared/ipc-commands.ts index 1a57b142b..a8ab6408c 100644 --- a/shared/ipc-commands.ts +++ b/shared/ipc-commands.ts @@ -44,7 +44,13 @@ export const AUTO_UPDATE_PLAYLISTS_RESPONSE = 'AUTO_UPDATE_RESPONSE'; // Experimental export const OPEN_MPV_PLAYER = 'OPEN_MPV_PLAYER'; export const SET_MPV_PLAYER_PATH = 'SET_MPV_PLAYER_PATH'; +export const OPEN_VLC_PLAYER = 'OPEN_VLC_PLAYER'; +export const SET_VLC_PLAYER_PATH = 'SET_VLC_PLAYER_PATH'; // Xtream export const XTREAM_REQUEST = 'XTREAM_REQUEST'; export const XTREAM_RESPONSE = 'XTREAM_RESPONSE'; + +// Stalker +export const STALKER_REQUEST = 'STALKER_REQUEST'; +export const STALKER_RESPONSE = 'STALKER_RESPONSE'; diff --git a/shared/playlist.interface.ts b/shared/playlist.interface.ts index 374c4877c..9afc1011b 100644 --- a/shared/playlist.interface.ts +++ b/shared/playlist.interface.ts @@ -32,4 +32,6 @@ export interface Playlist { serverUrl?: string; username?: string; password?: string; + macAddress?: string; + portalUrl?: string; } diff --git a/shared/playlist.utils.ts b/shared/playlist.utils.ts index 0aa3cc337..c78f1948c 100644 --- a/shared/playlist.utils.ts +++ b/shared/playlist.utils.ts @@ -13,7 +13,7 @@ export function aggregateFavoriteChannels(playlists: Playlist[]): Channel[] { const favorites = []; playlists.forEach((playlist) => { if (playlist.favorites?.length > 0) { - playlist.playlist.items.forEach((channel) => { + playlist?.playlist?.items.forEach((channel) => { if ( playlist.favorites.includes(channel.id) || playlist.favorites.includes(channel.url) diff --git a/shared/stalker-portal-actions.enum.ts b/shared/stalker-portal-actions.enum.ts new file mode 100644 index 000000000..372167685 --- /dev/null +++ b/shared/stalker-portal-actions.enum.ts @@ -0,0 +1,7 @@ +export enum StalkerPortalActions { + GetCategories = 'get_categories', + GetGenres = 'get_genres', + CreateLink = 'create_link', + GetOrderedList = 'get_ordered_list', + Favorites = 'favorites', +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index e7f084c48..135e5eb64 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -36,6 +36,13 @@ const routes: Routes = [ (c) => c.XtreamMainContainerComponent ), }, + { + path: 'portals/:id', + loadComponent: () => + import('./stalker/stalker-main-container.component').then( + (c) => c.StalkerMainContainerComponent + ), + }, { path: '**', redirectTo: '', diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 72da861a7..9d1690d05 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -46,9 +46,7 @@ - + @@ -59,14 +57,12 @@ - + - text_format + video_library
{{ 'HOME.TABS.XTREME_IMPORT' | translate }}
@@ -75,6 +71,17 @@
+ + + dashboard +
+ {{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }} +
+
+ + + +
diff --git a/src/app/home/home.module.ts b/src/app/home/home.module.ts index f645965e6..585e654cb 100644 --- a/src/app/home/home.module.ts +++ b/src/app/home/home.module.ts @@ -5,6 +5,7 @@ import { DragDropFileUploadDirective } from './file-upload/drag-drop-file-upload 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'; @@ -15,6 +16,7 @@ import { XtreamCodeImportComponent } from './xtream-code-import/xtream-code-impo HomeRoutingModule, SharedModule, XtreamCodeImportComponent, + StalkerPortalImportComponent, ], declarations: [ DragDropFileUploadDirective, diff --git a/src/app/home/recent-playlists/playlist-info/playlist-info.component.html b/src/app/home/recent-playlists/playlist-info/playlist-info.component.html index 24ae9072e..944161283 100644 --- a/src/app/home/recent-playlists/playlist-info/playlist-info.component.html +++ b/src/app/home/recent-playlists/playlist-info/playlist-info.component.html @@ -12,6 +12,18 @@ }} + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate + }} + + + + {{ + 'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate + }} + + {{ 'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate @@ -89,7 +101,7 @@ mat-flat-button color="accent" (click)="exportPlaylist()" - *ngIf="!playlist.serverUrl" + *ngIf="!playlist.serverUrl && !playlist.portalUrl" > {{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }} diff --git a/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts b/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts index 8f89a1b74..ae8901ad5 100644 --- a/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts +++ b/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts @@ -98,6 +98,8 @@ export class PlaylistInfoComponent { serverUrl: new FormControl(this.playlist.serverUrl), username: new FormControl(this.playlist.username), password: new FormControl(this.playlist.password), + macAddress: new FormControl(this.playlist.macAddress), + portalUrl: new FormControl(this.playlist.portalUrl), }); } diff --git a/src/app/home/recent-playlists/playlist-item/playlist-item.component.html b/src/app/home/recent-playlists/playlist-item/playlist-item.component.html index 6933b5b7f..e2a785b91 100644 --- a/src/app/home/recent-playlists/playlist-item/playlist-item.component.html +++ b/src/app/home/recent-playlists/playlist-item/playlist-item.component.html @@ -14,7 +14,7 @@ folder @@ -25,9 +25,18 @@ [matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate" >public + dashboard
{{ item.title || item.filename }}
- + {{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }} | {{ 'HOME.PLAYLISTS.ADDED' | translate }}: @@ -35,7 +44,7 @@ | {{ 'HOME.PLAYLISTS.UPDATED' | translate }}: - {{ item.updateDate | date : 'MMMM d, yyyy, HH:mm' }} + {{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }} Xtream Code Playlist + >Xtream/Stalker Playlist | {{ 'HOME.PLAYLISTS.ADDED' | translate }}: {{ item.importDate | date }} diff --git a/src/app/home/recent-playlists/recent-playlists.component.html b/src/app/home/recent-playlists/recent-playlists.component.html index ac5b2656a..bdb043a40 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.html +++ b/src/app/home/recent-playlists/recent-playlists.component.html @@ -1,10 +1,24 @@ + + + - + search
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }} @@ -13,51 +27,56 @@ {{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
- - star -
- {{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }} -
-
- {{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' | translate }} -
-
- - + + + star +
+ {{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }} +
+
+ {{ + 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' + | translate + }} +
+
+ -
-

{{ migrationMessage }}

-   - -
+ + +
+

{{ migrationMessage }}

+   + +
+
diff --git a/src/app/home/recent-playlists/recent-playlists.component.scss b/src/app/home/recent-playlists/recent-playlists.component.scss index a1390799d..5c2cd45cb 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.scss +++ b/src/app/home/recent-playlists/recent-playlists.component.scss @@ -2,6 +2,10 @@ padding-top: 0; } +.playlists-list { + padding: 0; +} + .meta { font-size: 12px !important; color: #666; diff --git a/src/app/home/recent-playlists/recent-playlists.component.ts b/src/app/home/recent-playlists/recent-playlists.component.ts index 19d0ef55d..0c89ef9fd 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.ts @@ -12,7 +12,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { map } from 'rxjs'; +import { BehaviorSubject, combineLatest, map } from 'rxjs'; import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../../shared/constants'; import { IpcCommand } from '../../../../shared/ipc-command.class'; import { Playlist } from '../../../../shared/playlist.interface'; @@ -42,9 +42,20 @@ import { PlaylistInfoComponent } from './playlist-info/playlist-info.component'; styleUrls: ['./recent-playlists.component.scss'], }) export class RecentPlaylistsComponent implements OnDestroy { - playlists$ = this.store.select(selectAllPlaylistsMeta).pipe( + searchQuery = new BehaviorSubject(''); + + playlists$ = combineLatest([ + this.store.select(selectAllPlaylistsMeta), + this.searchQuery, + ]).pipe( // eslint-disable-next-line @ngrx/avoid-mapping-selectors - map((playlists) => playlists.sort((a, b) => a.position - b.position)) + map(([playlists, searchQuery]) => + playlists + .sort((a, b) => a.position - b.position) + .filter((item) => + item.title.toLowerCase().includes(searchQuery.toLowerCase()) + ) + ) ); allPlaylistsLoaded$ = this.store.select(selectPlaylistsLoadingFlag); @@ -169,6 +180,8 @@ export class RecentPlaylistsComponent implements OnDestroy { getPlaylist(playlistMeta: PlaylistMeta): void { if (playlistMeta.serverUrl) { this.router.navigate(['xtreams', playlistMeta._id]); + } else if (playlistMeta.macAddress) { + this.router.navigate(['portals', playlistMeta._id]); } else { this.router.navigate(['playlists', playlistMeta._id]); this.playlistClicked.emit(playlistMeta._id); @@ -227,4 +240,12 @@ export class RecentPlaylistsComponent implements OnDestroy { ); } } + + trackByFn(_index: number, item: PlaylistMeta) { + return item._id; + } + + onSearchQueryUpdate(searchQuery: string) { + this.searchQuery.next(searchQuery); + } } diff --git a/src/app/home/stalker-portal-import/stalker-portal-import.component.html b/src/app/home/stalker-portal-import/stalker-portal-import.component.html new file mode 100644 index 000000000..5336ca695 --- /dev/null +++ b/src/app/home/stalker-portal-import/stalker-portal-import.component.html @@ -0,0 +1,54 @@ +
+ + {{ + 'HOME.STALKER_PORTAL.TITLE' | translate + }} + + + + {{ + 'HOME.STALKER_PORTAL.SERVER_URL' | translate + }} + + {{ + 'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate + }} + {{ 'SETTINGS.EPG_URL_ERROR' | translate }} + + + {{ + 'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate + }} + + + + +
diff --git a/src/app/home/stalker-portal-import/stalker-portal-import.component.ts b/src/app/home/stalker-portal-import/stalker-portal-import.component.ts new file mode 100644 index 000000000..dc0664bcd --- /dev/null +++ b/src/app/home/stalker-portal-import/stalker-portal-import.component.ts @@ -0,0 +1,90 @@ +import { Component, inject } from '@angular/core'; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { v4 as uuid } from 'uuid'; +import { Playlist } from '../../../../shared/playlist.interface'; +import { DataService } from '../../services/data.service'; +import { addPlaylist } from '../../state/actions'; + +@Component({ + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + TranslateModule, + ], + selector: 'app-stalker-portal-import', + templateUrl: './stalker-portal-import.component.html', + styles: [ + ` + :host { + display: flex; + margin: 10px; + justify-content: center; + } + + form { + width: 100%; + } + `, + ], +}) +export class StalkerPortalImportComponent { + URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; + + form = new FormGroup({ + _id: new FormControl(uuid()), + title: new FormControl('', [Validators.required]), + macAddress: new FormControl('', [Validators.required]), + password: new FormControl(''), + username: new FormControl(''), + portalUrl: new FormControl('', [ + Validators.required, + Validators.pattern(this.URL_REGEX), + ]), + importDate: new FormControl(new Date().toISOString()), + }); + + dataService = inject(DataService); + store = inject(Store); + + addPlaylist() { + this.form.value.portalUrl = this.transformPortalUrl( + this.form.value.portalUrl + ); + this.store.dispatch( + addPlaylist({ playlist: this.form.value as Playlist }) + ); + } + + transformPortalUrl(url: string) { + // if the url ends with "/c" it should be to end with "/portal.php" + if (url.endsWith('/c')) { + return url.replace('/c', '/portal.php'); + } + + // if the url ends with "/stalker_portal" it should be extended to "/stalker_portal/server/load.php" + if (url.endsWith('/stalker_portal')) { + return url.replace( + '/stalker_portal/c', + '/stalker_portal/server/load.php' + ); + } + + // otherwise keep the provided url + return url; + } +} diff --git a/src/app/player/components/audio-player/audio-player.component.scss b/src/app/player/components/audio-player/audio-player.component.scss new file mode 100644 index 000000000..f12080a44 --- /dev/null +++ b/src/app/player/components/audio-player/audio-player.component.scss @@ -0,0 +1,111 @@ +:host { + display: flex; + justify-content: center; + margin: 20px 0; +} + +#audio-player { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; +} + +.radio-logo { + border-radius: 14px; + height: 300px; + width: 300px; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: 300px; + max-height: 300px; + } +} + +.volume-panel { + display: flex; + align-self: center; + align-items: center; + + input { + flex: 1; + } +} + +.controls { + height: 100px; + align-items: center; + display: flex; + gap: 8px; + justify-content: center; +} + +.playing { + width: 36px; + height: 36px; + border-radius: 0.3rem; + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 0.5rem; + box-sizing: border-box; +} + +.playing-bar { + display: inline-block; + background: white; + width: 30%; + height: 100%; + animation: up-and-down 1.3s ease infinite alternate; +} + +.playing-bar-stopped { + display: inline-block; + background: white; + width: 30%; + height: 100%; +} + +.playing-bar1 { + height: 60%; +} + +.playing-bar2 { + height: 30%; + animation-delay: -2.2s; +} + +.playing-bar3 { + height: 75%; + animation-delay: -3.7s; +} + +@keyframes up-and-down { + 10% { + height: 30%; + } + + 30% { + height: 100%; + } + + 60% { + height: 50%; + } + + 80% { + height: 75%; + } + + 100% { + height: 60%; + } +} + +.icon-button-large { + transform: scale(2); + margin: 0 20px; +} diff --git a/src/app/player/components/audio-player/audio-player.component.ts b/src/app/player/components/audio-player/audio-player.component.ts new file mode 100644 index 000000000..b43ba4bb0 --- /dev/null +++ b/src/app/player/components/audio-player/audio-player.component.ts @@ -0,0 +1,134 @@ +import { NgClass, NgIf, NgOptimizedImage } from '@angular/common'; +import { + Component, + ElementRef, + Input, + OnChanges, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSliderModule } from '@angular/material/slider'; +import { Store } from '@ngrx/store'; +import { setAdjacentChannelAsActive } from '../../../state/actions'; + +@Component({ + selector: 'app-audio-player', + standalone: true, + template: ` +
+ + +
+ + + + + +
+
+
+ + + +
+
+ + + +
+ + + + +
+
+ `, + styleUrls: ['./audio-player.component.scss'], + imports: [ + MatSliderModule, + MatIconModule, + MatButtonModule, + NgIf, + NgClass, + FormsModule, + NgOptimizedImage, + ], +}) +export class AudioPlayerComponent implements OnChanges { + @Input() icon: string; + @Input({ required: true }) url: string; + + playState: 'play' | 'paused' = 'paused'; + + fallbackVolume: number; + + @ViewChild('audio', { static: true }) audio!: ElementRef; + + constructor(private store: Store) {} + + ngOnChanges(changes: SimpleChanges): void { + this.audio.nativeElement.src = changes.url.currentValue; + this.audio.nativeElement.load(); + this.play(); + } + + play() { + const playPromise = this.audio.nativeElement.play(); + if (playPromise !== undefined) { + playPromise.catch((error) => { + console.log(error); + }); + } + this.playState = 'play'; + } + + stop() { + this.audio.nativeElement.pause(); + this.playState = 'paused'; + } + + mute() { + this.audio.nativeElement.muted = !this.audio.nativeElement.muted; + if (this.audio.nativeElement.muted) { + this.fallbackVolume = this.audio.nativeElement.volume; + this.audio.nativeElement.volume = 0; + } else this.audio.nativeElement.volume = this.fallbackVolume; + } + + switchChannel(direction: 'next' | 'previous') { + console.log(direction); + this.store.dispatch(setAdjacentChannelAsActive({ direction })); + } +} diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.html b/src/app/player/components/channel-list-container/channel-list-container.component.html index 2859fa2a6..6cd745e44 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.html +++ b/src/app/player/components/channel-list-container/channel-list-container.component.html @@ -6,42 +6,34 @@ {{ 'CHANNELS.ALL_CHANNELS' | translate }}
- - - - - +
+ +
- - - - - + @@ -67,7 +59,11 @@ 0; else noFavorites"> - {{ dateToday | momentDate: 'YYYYMMDD':'MMMM Do, dddd' }} + *ngIf="channels$ | async as channels; else noChannels" + [channels]="channels" + /> + No channels
+
- + - - + + + + + + + +
+ +

+ The channel is played in a separate window in the MPV + player. (Installation instructions) +

+
+
+ +

+ The channel is played in a separate window in VLC + player. Please make sure that this player is installed + in your system and the correct path is defined in the + app settings. +

+
+ + +
- - - - - - -
- -

The channel is played in a separate window in the MPV player. (Installation instructions)

-
-
- - -
diff --git a/src/app/player/components/video-player/video-player.component.scss b/src/app/player/components/video-player/video-player.component.scss index ba6e71a73..f0f41314a 100644 --- a/src/app/player/components/video-player/video-player.component.scss +++ b/src/app/player/components/video-player/video-player.component.scss @@ -17,6 +17,10 @@ min-width: 400px; } +.custom-player { + padding: 20px; +} + @media only screen and (max-width: 480px) { .mat-drawer { width: 200px; @@ -25,11 +29,12 @@ } @media (prefers-color-scheme: dark) { - .mpv-player { + .custom-player { display: flex; margin-top: 50px; flex-direction: column; align-items: center; + padding: 10px; img { filter: invert(100%); diff --git a/src/app/player/components/video-player/video-player.component.ts b/src/app/player/components/video-player/video-player.component.ts index 1447f3df6..cd8b14369 100644 --- a/src/app/player/components/video-player/video-player.component.ts +++ b/src/app/player/components/video-player/video-player.component.ts @@ -142,6 +142,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } : {} ); + + this.store.dispatch( + PlaylistActions.setChannels({ + channels: playlist.playlist.items, + }) + ); return playlist.playlist.items; }) ); diff --git a/src/app/player/player.module.ts b/src/app/player/player.module.ts index 3a75d1662..65bfd6211 100644 --- a/src/app/player/player.module.ts +++ b/src/app/player/player.module.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { SharedModule } from '../shared/shared.module'; +import { AudioPlayerComponent } from './components/audio-player/audio-player.component'; import { ChannelListContainerComponent } from './components/channel-list-container/channel-list-container.component'; import { ChannelListItemComponent } from './components/channel-list-container/channel-list-item/channel-list-item.component'; import { EpgItemDescriptionComponent } from './components/epg-list/epg-item-description/epg-item-description.component'; @@ -20,6 +21,7 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }]; @NgModule({ imports: [ + AudioPlayerComponent, CommonModule, HtmlVideoPlayerComponent, OverlayModule, diff --git a/src/app/services/epg.service.ts b/src/app/services/epg.service.ts index e45b678a8..a4b0c6d45 100644 --- a/src/app/services/epg.service.ts +++ b/src/app/services/epg.service.ts @@ -13,32 +13,28 @@ export class EpgService { /** Default options for epg snackbar notifications */ epgSnackBarOptions: MatSnackBarConfig = { verticalPosition: 'bottom', - horizontalPosition: 'right', + horizontalPosition: 'start', }; constructor( - private readonly store: Store, + private store: Store, private electronService: DataService, private snackBar: MatSnackBar, private translate: TranslateService ) {} /** - * Fetches and updates EPG from the given URL + * Fetches and updates EPG from the given sources * @param urls epg source urls */ - fetchEpg(urls: string | string[]): void { - if (!Array.isArray(urls)) { - urls = [urls]; - } - if (urls?.length > 0) { - urls.forEach((url) => - this.electronService.sendIpcEvent(EPG_FETCH, { - url, - }) - ); - this.showFetchSnackbar(); - } + fetchEpg(urls: string | string[]) { + const urlsArray = Array.isArray(urls) ? urls : [urls]; + urlsArray.forEach((url) => + this.electronService.sendIpcEvent(EPG_FETCH, { + url, + }) + ); + this.showFetchSnackbar(); } showFetchSnackbar() { @@ -49,10 +45,7 @@ export class EpgService { ); } - /** - * Handles the event when the EPG fetching is done - */ - onEpgFetchDone(): void { + onEpgFetchDone() { this.store.dispatch(setEpgAvailableFlag({ value: true })); this.snackBar.open( this.translate.instant('EPG.DOWNLOAD_SUCCESS'), @@ -64,10 +57,7 @@ export class EpgService { ); } - /** - * Handles epg error - */ - onEpgError(): void { + onEpgError() { this.snackBar.open(this.translate.instant('EPG.ERROR'), null, { ...this.epgSnackBarOptions, duration: 2000, diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts index c6fbda466..e0a0d8264 100644 --- a/src/app/services/playlists.service.ts +++ b/src/app/services/playlists.service.ts @@ -80,6 +80,21 @@ export class PlaylistsService { title: updatedPlaylist.title, autoRefresh: updatedPlaylist.autoRefresh, userAgent: updatedPlaylist.userAgent, + ...(updatedPlaylist.serverUrl !== null + ? { serverUrl: updatedPlaylist.serverUrl } + : {}), + ...(updatedPlaylist.portalUrl !== null + ? { portalUrl: updatedPlaylist.portalUrl } + : {}), + ...(updatedPlaylist.macAddress !== null + ? { macAddress: updatedPlaylist.macAddress } + : {}), + ...(updatedPlaylist.username !== null + ? { username: updatedPlaylist.username } + : {}), + ...(updatedPlaylist.password !== null + ? { password: updatedPlaylist.password } + : {}), }) ) ); @@ -140,7 +155,7 @@ export class PlaylistsService { ); } - removeFromPortalFavorites(portalId: string, favoriteId: number) { + removeFromPortalFavorites(portalId: string, favoriteId: number | string) { return this.getPlaylistById(portalId).pipe( switchMap((portal) => this.dbService.update(DbStores.Playlists, { diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index ca7665e20..d9bbebf6a 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -10,6 +10,8 @@ import { PLAYLIST_PARSE_BY_URL, PLAYLIST_PARSE_RESPONSE, PLAYLIST_UPDATE, + STALKER_REQUEST, + STALKER_RESPONSE, XTREAM_REQUEST, XTREAM_RESPONSE, } from '../../../shared/ipc-commands'; @@ -71,6 +73,14 @@ export class PwaService extends DataService { this.forwardXtreamRequest( payload as { url: string; params: Record } ); + } else if (type === STALKER_REQUEST) { + this.forwardStalkerRequest( + payload as { + url: string; + macAddress: string; + params: Record; + } + ); } } @@ -145,13 +155,22 @@ export class PwaService extends DataService { forwardXtreamRequest(payload: { url: string; params: Record; + macAddress?: string; }) { + const headers = payload.macAddress + ? { + headers: { + Cookie: `mac=${payload.macAddress}`, + }, + } + : {}; return this.http .get(`${this.corsProxyUrl}/xtream`, { params: { url: payload.url, ...payload.params, }, + ...headers, }) .subscribe((response) => { window.postMessage({ @@ -162,6 +181,28 @@ export class PwaService extends DataService { }); } + forwardStalkerRequest(payload: { + url: string; + params: Record; + macAddress: string; + }) { + return this.http + .get(`${this.corsProxyUrl}/stalker`, { + params: { + url: payload.url, + ...payload.params, + macAddress: payload.macAddress, + }, + }) + .subscribe((response) => { + window.postMessage({ + type: STALKER_RESPONSE, + payload: (response as any).payload, + action: payload.params.action, + }); + }); + } + getPlaylistFromUrl(url: string) { return this.http.get(`${this.corsProxyUrl}/parse`, { params: { url }, diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 9a3704d21..d0b9ca3a9 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -16,8 +16,11 @@

Add single or multiple URLs as EPG sources

-
- +
+ {{ 'SETTINGS.EPG_URL_PLACEHOLDER' | translate }} @@ -110,6 +113,26 @@
+
+
+ {{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }} +

{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}

+
+
+ + {{ + 'SETTINGS.VLC_PLAYER_PATH' | translate + }} + + +
+
+
{{ 'SETTINGS.LANGUAGE' | translate }} diff --git a/src/app/settings/settings.component.spec.ts b/src/app/settings/settings.component.spec.ts index f636ce9f5..760112149 100644 --- a/src/app/settings/settings.component.spec.ts +++ b/src/app/settings/settings.component.spec.ts @@ -61,6 +61,7 @@ const DEFAULT_SETTINGS = { showCaptions: false, theme: Theme.LightTheme, mpvPlayerPath: '', + vlcPlayerPath: '', }; describe('SettingsComponent', () => { @@ -131,6 +132,7 @@ describe('SettingsComponent', () => { showCaptions: true, epgUrl: [], mpvPlayerPath: '', + vlcPlayerPath: '', }; let spyOnStorageGet; diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index f133b9ac5..10fcc4d66 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -1,9 +1,9 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { - UntypedFormArray, - UntypedFormBuilder, - UntypedFormControl, + FormArray, + FormBuilder, + FormControl, Validators, } from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -15,6 +15,7 @@ import * as semver from 'semver'; import { EPG_FORCE_FETCH, SET_MPV_PLAYER_PATH, + SET_VLC_PLAYER_PATH, } from '../../../shared/ipc-commands'; import { Playlist } from '../../../shared/playlist.interface'; import { DataService } from '../services/data.service'; @@ -43,6 +44,17 @@ export class SettingsComponent implements OnInit { /** Flag that indicates whether the app runs in electron environment */ isElectron = this.electronService.isElectron; + electronPlayers = [ + { + id: VideoPlayer.MPV, + label: 'MPV Player', + }, + { + id: VideoPlayer.VLC, + label: 'VLC', + }, + ]; + /** Player options */ players = [ { @@ -53,15 +65,7 @@ export class SettingsComponent implements OnInit { id: VideoPlayer.VideoJs, label: 'VideoJs Player', }, - ...(this.isElectron - ? - [ - { - id: VideoPlayer.MPV, - label: 'MPV Player', - }, - ] - : []), + ...(this.isElectron ? this.electronPlayers : []), ]; /** Current version of the app */ @@ -79,15 +83,16 @@ export class SettingsComponent implements OnInit { /** Settings form object */ settingsForm = this.formBuilder.group({ player: [VideoPlayer.VideoJs], - ...(this.isElectron ? { epgUrl: new UntypedFormArray([]) } : {}), + ...(this.isElectron ? { epgUrl: new FormArray([]) } : {}), language: Language.ENGLISH, showCaptions: false, theme: Theme.LightTheme, mpvPlayerPath: '', + vlcPlayerPath: '', }); /** Form array with epg sources */ - epgUrl = this.settingsForm.get('epgUrl') as UntypedFormArray; + epgUrl = this.settingsForm.get('epgUrl') as FormArray; /** * Creates an instance of SettingsComponent and injects @@ -97,7 +102,7 @@ export class SettingsComponent implements OnInit { private dialogService: DialogService, private electronService: DataService, private epgService: EpgService, - private formBuilder: UntypedFormBuilder, + private formBuilder: FormBuilder, private playlistsService: PlaylistsService, private router: Router, private settingsService: SettingsService, @@ -138,6 +143,7 @@ export class SettingsComponent implements OnInit { ? settings.theme : Theme.LightTheme, mpvPlayerPath: settings.mpvPlayerPath, + vlcPlayerPath: settings.vlcPlayerPath, }); if (this.isElectron) { @@ -154,16 +160,14 @@ export class SettingsComponent implements OnInit { setEpgUrls(epgUrls: string[] | string): void { const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; - if (!Array.isArray(epgUrls)) { - epgUrls = [epgUrls]; - } - epgUrls = epgUrls.filter((url) => url !== ''); + const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls]; + const filteredUrls = urls.filter((url) => url !== ''); - for (const url of epgUrls) { + filteredUrls.forEach((url) => { this.epgUrl.push( - new UntypedFormControl(url, [Validators.pattern(URL_REGEX)]) + new FormControl(url, [Validators.pattern(URL_REGEX)]) ); - } + }); } /** @@ -230,6 +234,11 @@ export class SettingsComponent implements OnInit { SET_MPV_PLAYER_PATH, this.settingsForm.value.mpvPlayerPath ); + + this.electronService.sendIpcEvent( + SET_VLC_PLAYER_PATH, + this.settingsForm.value.mpvPlayerPath + ); } /** @@ -255,7 +264,7 @@ export class SettingsComponent implements OnInit { null, { duration: 2000, - horizontalPosition: 'left', + horizontalPosition: 'start', } ); } @@ -280,7 +289,7 @@ export class SettingsComponent implements OnInit { * Initializes new entry in form array for EPG URL */ addEpgSource(): void { - this.epgUrl.insert(this.epgUrl.length, new UntypedFormControl('')); + this.epgUrl.insert(this.epgUrl.length, new FormControl('')); } /** @@ -319,34 +328,16 @@ export class SettingsComponent implements OnInit { const file = target.files?.[0]; if (file) { - if (file) { - const reader = new FileReader(); - reader.onload = () => { - const contents = reader.result; + const reader = new FileReader(); + reader.onload = () => { + const contents = reader.result; - try { - const parsedPlaylists: Playlist[] = JSON.parse( - contents.toString() - ); + try { + const parsedPlaylists: Playlist[] = JSON.parse( + contents.toString() + ); - if (!Array.isArray(parsedPlaylists)) { - this.snackBar.open( - this.translate.instant( - 'SETTINGS.IMPORT_ERROR' - ), - null, - { - duration: 2000, - } - ); - } else { - this.store.dispatch( - PlaylistActions.addManyPlaylists({ - playlists: parsedPlaylists, - }) - ); - } - } catch (error) { + if (!Array.isArray(parsedPlaylists)) { this.snackBar.open( this.translate.instant('SETTINGS.IMPORT_ERROR'), null, @@ -354,10 +345,24 @@ export class SettingsComponent implements OnInit { duration: 2000, } ); + } else { + this.store.dispatch( + PlaylistActions.addManyPlaylists({ + playlists: parsedPlaylists, + }) + ); } - }; - reader.readAsText(file); - } + } catch (error) { + this.snackBar.open( + this.translate.instant('SETTINGS.IMPORT_ERROR'), + null, + { + duration: 2000, + } + ); + } + }; + reader.readAsText(file); } }); diff --git a/src/app/settings/settings.interface.ts b/src/app/settings/settings.interface.ts index 605cf6507..f31a3f981 100644 --- a/src/app/settings/settings.interface.ts +++ b/src/app/settings/settings.interface.ts @@ -9,6 +9,7 @@ export enum VideoPlayer { VideoJs = 'videojs', Html5Player = 'html5', MPV = 'mpv', + VLC = 'vlc', } /** @@ -21,4 +22,5 @@ export interface Settings { showCaptions: boolean; theme: Theme; mpvPlayerPath: string; + vlcPlayerPath: string; } diff --git a/src/app/shared/components/external-player-info-dialog/external-player-info-dialog.component.ts b/src/app/shared/components/external-player-info-dialog/external-player-info-dialog.component.ts new file mode 100644 index 000000000..53678f42d --- /dev/null +++ b/src/app/shared/components/external-player-info-dialog/external-player-info-dialog.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { TranslateModule } from '@ngx-translate/core'; +import { PlayerViewComponent } from '../../../xtream/player-view/player-view.component'; + +@Component({ + standalone: true, + template: ` +

Information

+ + + + + + + `, + imports: [ + MatButtonModule, + MatDialogModule, + PlayerViewComponent, + TranslateModule, + ], +}) +export class ExternalPlayerInfoDialogComponent {} diff --git a/src/app/shared/playlist-meta.type.ts b/src/app/shared/playlist-meta.type.ts index bc9e4d424..29e09a2b5 100644 --- a/src/app/shared/playlist-meta.type.ts +++ b/src/app/shared/playlist-meta.type.ts @@ -18,4 +18,6 @@ export type PlaylistMeta = Pick< | 'serverUrl' | 'username' | 'password' + | 'macAddress' + | 'portalUrl' >; diff --git a/src/app/stalker/stalker-content-types.ts b/src/app/stalker/stalker-content-types.ts new file mode 100644 index 000000000..efb42ebe7 --- /dev/null +++ b/src/app/stalker/stalker-content-types.ts @@ -0,0 +1,20 @@ +import { StalkerPortalActions } from '../../../shared/stalker-portal-actions.enum'; + +export const StalkerContentTypes = { + stb: { + doAuth: 'do_auth', + handshake: 'handshake', + }, + itv: { + title: 'Live streams', + getContentAction: StalkerPortalActions.GetOrderedList, + getCategoryAction: StalkerPortalActions.GetGenres, + getLink: StalkerPortalActions.CreateLink, + }, + vod: { + title: 'VOD streams', + getContentAction: StalkerPortalActions.GetOrderedList, + getCategoryAction: StalkerPortalActions.GetCategories, + getLink: StalkerPortalActions.CreateLink, + }, +}; diff --git a/src/app/stalker/stalker-main-container.component.html b/src/app/stalker/stalker-main-container.component.html new file mode 100644 index 000000000..62530d445 --- /dev/null +++ b/src/app/stalker/stalker-main-container.component.html @@ -0,0 +1,48 @@ + + + + + + + + + + + diff --git a/src/app/stalker/stalker-main-container.component.ts b/src/app/stalker/stalker-main-container.component.ts new file mode 100644 index 000000000..81aa918d4 --- /dev/null +++ b/src/app/stalker/stalker-main-container.component.ts @@ -0,0 +1,435 @@ +import { AsyncPipe, NgIf } from '@angular/common'; +import { Component, NgZone, OnInit, Signal, effect } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatDialog } from '@angular/material/dialog'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { Observable } from 'rxjs'; +import { IpcCommand } from '../../../shared/ipc-command.class'; +import { + ERROR, + OPEN_MPV_PLAYER, + OPEN_VLC_PLAYER, + STALKER_REQUEST, + STALKER_RESPONSE, +} from '../../../shared/ipc-commands'; +import { StalkerPortalActions } from '../../../shared/stalker-portal-actions.enum'; +import { DataService } from '../services/data.service'; +import { PlaylistsService } from '../services/playlists.service'; +import { Settings, VideoPlayer } from '../settings/settings.interface'; +import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component'; +import { STORE_KEY } from '../shared/enums/store-keys.enum'; +import { selectCurrentPlaylist } from '../state/selectors'; +import { Breadcrumb } from '../xtream/breadcrumb.interface'; +import { CategoryContentViewComponent } from '../xtream/category-content-view/category-content-view.component'; +import { CategoryViewComponent } from '../xtream/category-view/category-view.component'; +import { ContentTypeNavigationItem } from '../xtream/content-type-navigation-item.interface'; +import { ContentType } from '../xtream/content-type.enum'; +import { NavigationBarComponent } from '../xtream/navigation-bar/navigation-bar.component'; +import { PlayerDialogComponent } from '../xtream/player-dialog/player-dialog.component'; +import { PortalStore } from '../xtream/portal.store'; +import { VodDetailsComponent } from '../xtream/vod-details/vod-details.component'; +import { StalkerContentTypes } from './stalker-content-types'; + +@Component({ + selector: 'app-stalker-main-container', + templateUrl: './stalker-main-container.component.html', + standalone: true, + imports: [ + AsyncPipe, + CategoryViewComponent, + CategoryContentViewComponent, + NavigationBarComponent, + NgIf, + MatPaginatorModule, + VodDetailsComponent, + ], +}) +export class StalkerMainContainerComponent implements OnInit { + breadcrumbs: Breadcrumb[] = []; + currentPlaylist = this.store.selectSignal(selectCurrentPlaylist); + listeners = []; + isLoading = true; + selectedContentType: ContentType = ContentType.VODS; + currentLayout = 'category'; + searchPhrase = this.portalStore.searchPhrase(); + settings = toSignal( + this.storage.get(STORE_KEY.Settings) + ) as Signal; + favorites$: Observable; + + items: any[] = []; + itemDetails!: { + id: string; + cmd: string; + info: { + movie_image: string; + description: string; + name: string; + actors: string; + director: string; + releasedate: string; + genre: string; + }; + }; + + navigationContentTypes: ContentTypeNavigationItem[] = [ + { + contentType: ContentType.ITV, + label: 'Live Streams', + }, + { + contentType: ContentType.VODS, + label: 'VOD Streams', + }, + ]; + + currentCategoryId; + + //pagination + length = 0; + pageSize = 14; + pageIndex = 0; + + commandsList = [ + new IpcCommand(STALKER_RESPONSE, (response: any) => + this.handleResponse(response) + ), + new IpcCommand(ERROR, (response: { message: string; status: number }) => + this.showErrorAsNotification(response) + ), + ]; + + constructor( + private dataService: DataService, + private dialog: MatDialog, + private ngZone: NgZone, + private playlistService: PlaylistsService, + private portalStore: PortalStore, + private snackBar: MatSnackBar, + private storage: StorageMap, + private store: Store + ) { + effect(() => { + if (this.currentPlaylist()) { + if ( + this.currentPlaylist().password && + this.currentPlaylist().username + ) { + this.handshake(); + } else { + this.selectedContentType = ContentType.VODS; + this.getCategories(this.selectedContentType); + } + + this.favorites$ = this.playlistService.getPortalFavorites( + this.currentPlaylist()._id + ); + } + }); + + this.portalStore.setSearchPhrase(''); + } + + ngOnInit() { + this.commandsList.forEach((command) => { + if (this.dataService.isElectron) { + this.dataService.listenOn(command.id, (_event, response) => + this.ngZone.run(() => command.callback(response)) + ); + } else { + const cb = (response) => { + if (response.data.type === command.id) { + command.callback(response.data); + } + }; + this.dataService.listenOn(command.id, cb); + this.listeners.push(cb); + } + }); + } + + handshake() { + this.sendRequest({ action: 'handshake', type: 'stb' }); + } + + setInitialBreadcrumb( + action = StalkerPortalActions.GetCategories, + title = 'All categories' + ) { + this.breadcrumbs = [{ title, action }]; + } + + getCategories(contentType: ContentType = this.selectedContentType) { + this.selectedContentType = contentType; + this.currentLayout = 'category'; + const action = StalkerContentTypes[contentType].getCategoryAction; + this.setInitialBreadcrumb(action); + this.sendRequest({ action, type: contentType }); + } + + sendRequest(params: Record) { + this.isLoading = true; + if (params.action !== 'create_link') this.items = []; + const { portalUrl, macAddress } = this.currentPlaylist(); + let token = {}; + if (sessionStorage.getItem(this.currentPlaylist()._id)) { + token = sessionStorage.getItem(this.currentPlaylist()._id); + } + this.dataService.sendIpcEvent(STALKER_REQUEST, { + url: portalUrl, + macAddress, + params: { + ...params, + token, + }, + }); + } + + showErrorAsNotification(response: { message: string; status: number }) { + this.snackBar.open( + `Error: ${response?.message ?? 'Something went wrong'} (Status: ${ + response?.status ?? 0 + })`, + null, + { duration: 4000 } + ); + this.isLoading = false; + } + + handleResponse(response: { + action: string; + payload: { js: any; cmd?: string }; + }) { + if ( + response.action === StalkerPortalActions.GetCategories || + response.action === StalkerPortalActions.GetGenres + ) { + if (typeof response.payload !== 'object') return; + this.items = response.payload.js.map((item) => ({ + category_name: item.title, + category_id: item.id, + })); + } else if (response.action === StalkerPortalActions.GetOrderedList) { + if (response.payload.js.data && response.payload.js.data.length > 0) + this.portalStore.setContent(response.payload.js.data); + + this.items = response.payload.js.data.map((item) => ({ + ...item, + cover: item.screenshot_uri, + })); + this.length = response.payload.js.total_items; + this.pageIndex = response.payload.js.cur_page; + } else if (response.action === StalkerPortalActions.CreateLink) { + let url = response.payload.js.cmd as string; + if (url?.startsWith('ffmpeg')) { + url = url.split(' ')[1]; + } + + this.openPlayer(url, response.payload.js.name); + } else if (response.action === 'handshake') { + const token = response.payload.js.token; + sessionStorage.setItem(this.currentPlaylist()._id, token); + + this.sendRequest({ + action: 'do_auth', + login: this.currentPlaylist().username, + password: this.currentPlaylist().password, + type: 'stb', + token, + }); + } else if (response.action === 'do_auth') { + this.getCategories(); + } + } + + openPlayer(streamUrl: string, title: string) { + const player = this.settings().player; + if (player === VideoPlayer.MPV) { + this.dialog.open(ExternalPlayerInfoDialogComponent); + this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { + url: streamUrl, + }); + } else if (player === VideoPlayer.VLC) { + this.dialog.open(ExternalPlayerInfoDialogComponent); + this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { + url: streamUrl, + }); + } else { + this.dialog.open(PlayerDialogComponent, { + data: { streamUrl, player, title }, + width: '80%', + }); + } + } + + categoryClicked(item: { category_name: string; category_id: string }) { + this.currentLayout = 'category_content'; + this.currentCategoryId = item.category_id; + const action = + StalkerContentTypes[this.selectedContentType].getContentAction; + this.portalStore.setSearchPhrase(''); + + this.breadcrumbs.push({ + title: item.category_name, + category_id: item.category_id, + action, + }); + + this.sendRequest({ + action, + type: this.selectedContentType, + category: item.category_id, + genre: item.category_id, + }); + } + + itemClicked(i: any) { + if (this.selectedContentType === ContentType.ITV) { + this.playVod(i.cmd); + return; + } + const selectedContent = this.portalStore.getContentById(i.id)(); + this.itemDetails = !selectedContent + ? i.details // to read from favorites + : { + id: i.id, + cmd: selectedContent.cmd, + info: { + movie_image: selectedContent.screenshot_uri, + description: selectedContent.description, + name: selectedContent.name, + director: selectedContent.director, + releasedate: selectedContent.year, + genre: selectedContent.genres_str, + actors: selectedContent.actors, + }, + }; + this.breadcrumbs.push({ + title: this.itemDetails?.info?.name, + action: StalkerPortalActions.GetOrderedList, + }); + this.currentLayout = 'vod-details'; + } + + playVod(cmd?: string) { + const command = cmd ?? this.itemDetails.cmd; + const action = StalkerContentTypes[this.selectedContentType].getLink; + this.sendRequest({ + action, + type: this.selectedContentType, + cmd: command, + forced_storage: 'undefined', + disable_ad: '0', + JsHttpRequest: '1-xml', + }); + } + + breadcrumbClicked(breadcrumb: Breadcrumb) { + const itemIndex = this.breadcrumbs.findIndex((i) => i === breadcrumb); + + // do nothing if last breadcrumb child was clicked + if (itemIndex === this.breadcrumbs.length - 1) return; + + this.breadcrumbs.splice( + itemIndex + 1, + this.breadcrumbs.length - itemIndex - 1 + ); + + if (breadcrumb.action === StalkerPortalActions.GetOrderedList) { + this.currentLayout = 'category_content'; + } else if ( + breadcrumb.action === StalkerPortalActions.GetCategories || + breadcrumb.action === StalkerPortalActions.GetGenres + ) { + this.currentLayout = 'category'; + } else if (breadcrumb.action === StalkerPortalActions.Favorites) { + this.currentLayout = 'favorites'; + } + + this.currentCategoryId = breadcrumb.category_id; + this.sendRequest({ + action: breadcrumb.action, + type: this.selectedContentType, + ...(breadcrumb.category_id + ? { + category: breadcrumb.category_id, + genre: breadcrumb.category_id, + } + : {}), + }); + } + + addToFavorites(item: any) { + this.playlistService + .addPortalFavorite(this.currentPlaylist()._id, item) + .subscribe(() => { + this.snackBar.open('Added to favorites', null, { + duration: 1000, + }); + }); + } + + removeFromFavorites(favoriteId: string) { + this.playlistService + .removeFromPortalFavorites(this.currentPlaylist()._id, favoriteId) + .subscribe(() => { + this.snackBar.open('Removed from favorites', null, { + duration: 1000, + }); + }); + } + + setSearchPhrase(searchPhrase: string) { + if ( + this.currentLayout === 'category_content' && + this.searchPhrase !== searchPhrase + ) { + this.searchPhrase = searchPhrase; + this.sendRequest({ + action: StalkerContentTypes[this.selectedContentType] + .getContentAction, + type: this.selectedContentType, + search: searchPhrase, + ...(this.currentCategoryId + ? { + category: this.currentCategoryId, + genre: this.currentCategoryId, + } + : {}), + }); + } + } + + handlePageChange(event: PageEvent) { + this.sendRequest({ + action: StalkerContentTypes[this.selectedContentType] + .getContentAction, + type: this.selectedContentType, + category: this.currentCategoryId, + p: event.pageIndex + 1, + }); + } + + favoritesClicked() { + this.currentLayout = 'favorites'; + this.setInitialBreadcrumb( + StalkerPortalActions.Favorites, + 'My favorites' + ); + } + + ngOnDestroy(): void { + if (this.dataService.isElectron) { + this.commandsList.forEach((command) => + this.dataService.removeAllListeners(command.id) + ); + } else { + this.listeners.forEach((listener) => { + window.removeEventListener('message', listener); + }); + } + } +} diff --git a/src/app/state/actions.ts b/src/app/state/actions.ts index cfb0ca544..e9d146084 100644 --- a/src/app/state/actions.ts +++ b/src/app/state/actions.ts @@ -119,3 +119,10 @@ export const updatePlaylistPositions = createAction( export const removeAllPlaylists = createAction( `${STORE_KEY} Remove all playlists` ); + +export const setAdjacentChannelAsActive = createAction( + `${STORE_KEY} Set adjacent channel as active`, + props<{ + direction: 'next' | 'previous'; + }>() +); diff --git a/src/app/state/effects.ts b/src/app/state/effects.ts index be251e84a..2eb0677c3 100644 --- a/src/app/state/effects.ts +++ b/src/app/state/effects.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; @@ -6,12 +5,19 @@ import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom } from 'rxjs'; -import { combineLatestWith, map, switchMap, tap } from 'rxjs/operators'; +import { + combineLatestWith, + firstValueFrom, + map, + switchMap, + tap, + withLatestFrom, +} from 'rxjs'; import { CHANNEL_SET_USER_AGENT, EPG_GET_PROGRAM, OPEN_MPV_PLAYER, + OPEN_VLC_PLAYER, } from '../../../shared/ipc-commands'; import { DataService } from '../services/data.service'; import { PlaylistsService } from '../services/playlists.service'; @@ -19,7 +25,9 @@ import { Settings, VideoPlayer } from '../settings/settings.interface'; import { STORE_KEY } from '../shared/enums/store-keys.enum'; import * as PlaylistActions from './actions'; import { + selectActive, selectActivePlaylistId, + selectChannels, selectFavorites, selectIsEpgAvailable, } from './selectors'; @@ -34,7 +42,7 @@ export class PlaylistEffects { this.store.select(selectFavorites), this.store.select(selectActivePlaylistId) ), - switchMap(([action, favorites, playlistId]) => + switchMap(([, favorites, playlistId]) => this.playlistsService.updateFavorites(playlistId, favorites) ) ); @@ -60,6 +68,42 @@ export class PlaylistEffects { } ); + setActiveEpgProgram$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.setActiveEpgProgram), + combineLatestWith(this.store.select(selectActive)), + map(([, activeChannel]) => { + firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( + (settings: Settings) => { + if ( + settings && + Object.keys(settings).length > 0 && + settings.player === VideoPlayer.MPV + ) + this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { + url: + activeChannel.url + + activeChannel.epgParams ?? '', + }); + else if ( + settings && + Object.keys(settings).length > 0 && + settings.player === VideoPlayer.VLC + ) + this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { + url: + activeChannel.url + + activeChannel.epgParams ?? '', + }); + } + ); + }) + ); + }, + { dispatch: false } + ); + setActiveChannel$ = createEffect(() => { return this.actions$.pipe( ofType(PlaylistActions.setActiveChannel), @@ -83,11 +127,20 @@ export class PlaylistEffects { if ( settings && Object.keys(settings).length > 0 && - settings.player === VideoPlayer.MPV - ) + settings.player === VideoPlayer.MPV && + channel.radio !== 'true' + ) { this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: channel.url, }); + } else if ( + settings && + Object.keys(settings).length > 0 && + settings.player === VideoPlayer.VLC + ) + this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { + url: channel.url, + }); } ); @@ -169,6 +222,8 @@ export class PlaylistEffects { tap((playlist) => { if (playlist.serverUrl) { this.router.navigate(['/xtreams/', playlist._id]); + } else if (playlist.macAddress) { + this.router.navigate(['portals', playlist._id]); } else { this.router.navigate(['/playlists/', playlist._id]); } @@ -247,6 +302,34 @@ export class PlaylistEffects { { dispatch: false } ); + setAdjacentChannelAsActive$ = createEffect(() => { + return this.actions$.pipe( + ofType(PlaylistActions.setAdjacentChannelAsActive), + withLatestFrom( + this.store.select(selectChannels), + this.store.select(selectActive) + ), + map(([action, channels, activeChannel]) => { + let adjacentChannel; + const index = channels.findIndex( + (channel) => channel.id === activeChannel.id + ); + if (action.direction === 'next') { + if (index === channels.length - 1) + adjacentChannel = activeChannel; + adjacentChannel = channels[index + 1]; + } else if (action.direction === 'previous') { + if (index === -1 || index === 0) + adjacentChannel = activeChannel; + adjacentChannel = channels[index - 1]; + } + return PlaylistActions.setActiveChannelSuccess({ + channel: adjacentChannel, + }); + }) + ); + }); + constructor( private actions$: Actions, private playlistsService: PlaylistsService, diff --git a/src/app/state/reducers.ts b/src/app/state/reducers.ts index 9a5c9a5cd..dd8389be9 100644 --- a/src/app/state/reducers.ts +++ b/src/app/state/reducers.ts @@ -181,15 +181,21 @@ export const playlistReducer = createReducer( title: action.playlist.title, autoRefresh: action.playlist.autoRefresh || false, userAgent: action.playlist.userAgent, - ...(action.playlist.serverUrl + ...(action.playlist.serverUrl !== null ? { serverUrl: action.playlist.serverUrl } : {}), - ...(action.playlist.username + ...(action.playlist.username !== null ? { username: action.playlist.username } : {}), - ...(action.playlist.password + ...(action.playlist.password !== null ? { password: action.playlist.password } : {}), + ...(action.playlist.macAddress !== null + ? { macAddress: action.playlist.macAddress } + : {}), + ...(action.playlist.portalUrl !== null + ? { portalUrl: action.playlist.portalUrl } + : {}), }, }, state.playlists diff --git a/src/app/xtream/breadcrumb.interface.ts b/src/app/xtream/breadcrumb.interface.ts index dfb2d38bf..f619a3c3a 100644 --- a/src/app/xtream/breadcrumb.interface.ts +++ b/src/app/xtream/breadcrumb.interface.ts @@ -1,7 +1,10 @@ +import { StalkerPortalActions } from '../../../shared/stalker-portal-actions.enum'; import { XtreamCodeActions } from '../../../shared/xtream-code-actions'; +export type PortalActions = StalkerPortalActions | XtreamCodeActions; + export interface Breadcrumb { - action: XtreamCodeActions; + action: PortalActions; title: string; category_id?: string; } diff --git a/src/app/xtream/category-content-view/category-content-view.component.html b/src/app/xtream/category-content-view/category-content-view.component.html index a5a4444d6..b6147cb98 100644 --- a/src/app/xtream/category-content-view/category-content-view.component.html +++ b/src/app/xtream/category-content-view/category-content-view.component.html @@ -1,8 +1,9 @@
search
Nothing found, try to change you search request
diff --git a/src/app/xtream/category-content-view/category-content-view.component.scss b/src/app/xtream/category-content-view/category-content-view.component.scss index 0df8317a9..d0df179e6 100644 --- a/src/app/xtream/category-content-view/category-content-view.component.scss +++ b/src/app/xtream/category-content-view/category-content-view.component.scss @@ -3,6 +3,15 @@ padding: 20px; } +mat-card { + overflow: hidden; + transition: all 0.9s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +mat-card:hover { + box-shadow: 0 14px 28px rgb(0 0 0 / 25%), 0 10px 10px rgb(0 0 0 / 22%); +} + .grid { display: flex; flex-wrap: wrap; @@ -11,7 +20,7 @@ mat-card { cursor: pointer; - width: 200px; + width: 170px; mat-card-content { padding: 0; @@ -26,13 +35,14 @@ .stream-icon-placeholder { width: 100%; background: #999; - height: 300px; + max-height: 300px; + min-height: 130px; + display: flex; + justify-content: center; + align-items: center; &::after { content: 'No cover'; - position: relative; - top: 140px; - left: 70px; } } @@ -52,13 +62,3 @@ width: 64px; } } - -mat-card { - overflow: hidden; - - transition: all 0.9s cubic-bezier(0.25, 0.8, 0.25, 1); -} - -mat-card:hover { - box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); -} diff --git a/src/app/xtream/category-view/category-view.component.ts b/src/app/xtream/category-view/category-view.component.ts index 12654e509..e80d25e6e 100644 --- a/src/app/xtream/category-view/category-view.component.ts +++ b/src/app/xtream/category-view/category-view.component.ts @@ -14,10 +14,11 @@ import { PortalStore } from '../portal.store';
diff --git a/src/app/xtream/content-type-navigation-item.interface.ts b/src/app/xtream/content-type-navigation-item.interface.ts new file mode 100644 index 000000000..70d976686 --- /dev/null +++ b/src/app/xtream/content-type-navigation-item.interface.ts @@ -0,0 +1,6 @@ +import { ContentType } from './content-type.enum'; + +export interface ContentTypeNavigationItem { + contentType: ContentType; + label: string; +} \ No newline at end of file diff --git a/src/app/xtream/content-type.enum.ts b/src/app/xtream/content-type.enum.ts index 7125355a3..af7a8ef05 100644 --- a/src/app/xtream/content-type.enum.ts +++ b/src/app/xtream/content-type.enum.ts @@ -1,5 +1,6 @@ export enum ContentType { - LIVE_STREAMS = 'LIVE_STREAMS', - VODS = 'VODS', - SERIES = 'SERIES', + VODS = 'vod', + SERIES = 'series', + ITV = 'itv', + FAVORITES = 'favorites', } diff --git a/src/app/xtream/detail-view.scss b/src/app/xtream/detail-view.scss index 1d93e486e..6a9acc43e 100644 --- a/src/app/xtream/detail-view.scss +++ b/src/app/xtream/detail-view.scss @@ -23,7 +23,7 @@ .image { width: 300px; - padding: 10px; + padding: 20px 10px; .placeholder-cover { width: 300px; @@ -42,6 +42,7 @@ .image img { max-width: 100%; height: auto; + border-radius: 5px; } @media (max-width: 800px) { diff --git a/src/app/xtream/navigation-bar/navigation-bar.component.html b/src/app/xtream/navigation-bar/navigation-bar.component.html index c7798b890..1cbfe90e7 100644 --- a/src/app/xtream/navigation-bar/navigation-bar.component.html +++ b/src/app/xtream/navigation-bar/navigation-bar.component.html @@ -8,17 +8,17 @@ name="contentType" aria-label="Content Type" [value]="contentType" - (change)="contentTypeChanged.emit($event.value)" + (change)="changeContentType($event.value)" > - Live Streams - VOD Streams - Series + {{ item.label }} +
@@ -28,11 +28,15 @@
diff --git a/src/app/xtream/navigation-bar/navigation-bar.component.scss b/src/app/xtream/navigation-bar/navigation-bar.component.scss index ae09b0113..6f7356268 100644 --- a/src/app/xtream/navigation-bar/navigation-bar.component.scss +++ b/src/app/xtream/navigation-bar/navigation-bar.component.scss @@ -16,24 +16,27 @@ #sub-panel { background: white; border-bottom: 1px solid #ccc; - padding: 0 10px 5px; + padding: 0 1em; display: flex; justify-content: space-between; align-items: center; - height: 30px; + height: 40px; .breadcrumbs { - span { - cursor: pointer; + display: flex; + + div { + display: flex; + align-items: center; &:not(:last-child)::after { - content: '>'; - margin: 0 10px; + display: none; } - } - span:hover { - text-decoration: underline; + span:hover { + text-decoration: underline; + cursor: pointer; + } } } @@ -52,9 +55,11 @@ } } +/* stylelint-disable-next-line no-descending-specificity */ ::ng-deep { .dark-theme { - #navigation, #sub-panel { + #navigation, + #sub-panel { background: black; } diff --git a/src/app/xtream/navigation-bar/navigation-bar.component.ts b/src/app/xtream/navigation-bar/navigation-bar.component.ts index 3d00134c9..9c7477d88 100644 --- a/src/app/xtream/navigation-bar/navigation-bar.component.ts +++ b/src/app/xtream/navigation-bar/navigation-bar.component.ts @@ -5,7 +5,9 @@ import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconModule } from '@angular/material/icon'; import { RouterLink } from '@angular/router'; +import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'; import { Breadcrumb } from '../breadcrumb.interface'; +import { ContentTypeNavigationItem } from '../content-type-navigation-item.interface'; import { ContentType } from '../content-type.enum'; import { PortalStore } from '../portal.store'; @@ -28,14 +30,26 @@ export class NavigationBarComponent { @Input({ required: true }) breadcrumbs: Breadcrumb[]; @Input({ required: true }) contentType: ContentType; @Input() searchVisible = true; + @Input() contentTypeNavigationItems: ContentTypeNavigationItem[]; + @Input() clientSideSearch = true; @Output() contentTypeChanged = new EventEmitter(); @Output() breadcrumbClicked = new EventEmitter(); @Output() favoritesClicked = new EventEmitter(); + @Output() searchTextChanged = new EventEmitter(); ContentTypeEnum = ContentType; portalStore = inject(PortalStore); searchPhrase = this.portalStore.searchPhrase; + searchPhraseUpdate = new Subject(); + + constructor() { + this.searchPhraseUpdate + .pipe(debounceTime(600), distinctUntilChanged()) + .subscribe((value) => { + this.setSearchText(value); + }); + } processBreadcrumbClick(item: Breadcrumb) { this.setSearchText(''); @@ -43,6 +57,16 @@ export class NavigationBarComponent { } setSearchText(text: string) { - this.portalStore.setSearchPhrase(text); + if (this.clientSideSearch) this.portalStore.setSearchPhrase(text); + else this.searchTextChanged.emit(text); + } + + changeContentType(type: ContentType) { + this.setSearchText(''); + this.contentTypeChanged.emit(type); + } + + trackByValue(_i: number, value: ContentTypeNavigationItem) { + return value.contentType; } } diff --git a/src/app/xtream/portal-card-item.interface.ts b/src/app/xtream/portal-card-item.interface.ts new file mode 100644 index 000000000..8812636d0 --- /dev/null +++ b/src/app/xtream/portal-card-item.interface.ts @@ -0,0 +1,10 @@ +// should represent the content of a card element - xtream category or VOD or any item from stalker, vportal etc +export interface PortalCardItem { + // TODO + id: string; + type: 'category' | 'vod' | 'serial' | 'live'; // TODO: enum + name: string; + coverUrl?: string; + streamType?: 'live' | 'movie'; // TODO: maybe not needed, since type is there + categoryId?: string; +} diff --git a/src/app/xtream/portal.store.ts b/src/app/xtream/portal.store.ts index 10c311a7c..299e2a908 100644 --- a/src/app/xtream/portal.store.ts +++ b/src/app/xtream/portal.store.ts @@ -3,6 +3,7 @@ import { ComponentStore } from '@ngrx/component-store'; export interface PortalState { searchPhrase: string; + content: any[]; } @Injectable({ providedIn: 'root' }) @@ -10,12 +11,16 @@ export class PortalStore extends ComponentStore { constructor() { super({ searchPhrase: '', + content: [], }); } // selectors readonly searchPhrase = this.selectSignal((state) => state.searchPhrase); + readonly getContentById = (id: string) => + this.selectSignal((state) => state.content.find((i) => i.id === id)); + // reducers readonly setSearchPhrase = this.updater( (state, searchPhrase: string): PortalState => ({ @@ -23,4 +28,11 @@ export class PortalStore extends ComponentStore { searchPhrase, }) ); + + readonly setContent = this.updater( + (state, content: any[]): PortalState => ({ + ...state, + content, + }) + ); } diff --git a/src/app/xtream/vod-details/vod-details.component.html b/src/app/xtream/vod-details/vod-details.component.html index 2bbfb866f..24e851bf0 100644 --- a/src/app/xtream/vod-details/vod-details.component.html +++ b/src/app/xtream/vod-details/vod-details.component.html @@ -10,8 +10,8 @@ >
@@ -23,27 +23,27 @@ {{ item.info.description }}
-
Actors:
+
{{ 'XTREAM.ACTORS' | translate }}:
{{ item.info.actors }}
@@ -52,7 +52,7 @@ color="accent" (click)="playClicked.emit(item)" > - Play  @@ -69,7 +70,8 @@ color="accent" (click)="toggleFavorite()" > - starRemove from favorites + star + {{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
diff --git a/src/app/xtream/vod-details/vod-details.component.ts b/src/app/xtream/vod-details/vod-details.component.ts index 5aa0b3f5a..c38cd8380 100644 --- a/src/app/xtream/vod-details/vod-details.component.ts +++ b/src/app/xtream/vod-details/vod-details.component.ts @@ -1,4 +1,4 @@ -import { JsonPipe, NgIf } from '@angular/common'; +import { NgIf } from '@angular/common'; import { Component, EventEmitter, @@ -10,6 +10,7 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { XtreamVodDetails } from '../../../../shared/xtream-vod-details.interface'; import { PlaylistsService } from '../../services/playlists.service'; @@ -18,7 +19,7 @@ import { PlaylistsService } from '../../services/playlists.service'; templateUrl: './vod-details.component.html', styleUrls: ['../detail-view.scss'], standalone: true, - imports: [JsonPipe, MatButtonModule, NgIf, MatIconModule], + imports: [MatButtonModule, NgIf, MatIconModule, TranslateModule], }) export class VodDetailsComponent implements OnInit { @Input({ required: true }) item: XtreamVodDetails; @@ -42,7 +43,9 @@ export class VodDetailsComponent implements OnInit { .getPortalFavorites(this.portalId) .subscribe((favorites) => { this.isFavorite = favorites.some( - (i) => i.stream_id === this.item.movie_data.stream_id + (i) => + i?.stream_id === this.item?.movie_data?.stream_id || + (i as any)?.details?.id === (this.item as any)?.id ); }); } @@ -50,16 +53,28 @@ export class VodDetailsComponent implements OnInit { toggleFavorite() { if (this.isFavorite) { this.removeFromFavoritesClicked.emit( - this.item.movie_data.stream_id + this.item?.movie_data?.stream_id || (this.item as any)?.id ); } else { - this.addToFavoritesClicked.emit({ - name: this.item.movie_data.name, - stream_id: this.item.movie_data.stream_id, - container_extension: this.item.movie_data.container_extension, - cover: this.item.info.movie_image, - stream_type: 'movie', - }); + // stalker mode + if ((this.item as any).cmd) { + this.addToFavoritesClicked.emit({ + name: this.item.info.name, + stream_id: (this.item as any).id, + cover: this.item.info.movie_image, + cmd: (this.item as any).cmd || '', + details: this.item, + }); + } else { + this.addToFavoritesClicked.emit({ + name: this.item.info.name, + stream_id: this.item.movie_data.stream_id, + container_extension: + this.item.movie_data.container_extension, + cover: this.item.info.movie_image, + stream_type: 'movie', + }); + } } this.isFavorite = !this.isFavorite; } diff --git a/src/app/xtream/xtream-main-container.component.html b/src/app/xtream/xtream-main-container.component.html index e48a0c633..96315d16f 100644 --- a/src/app/xtream/xtream-main-container.component.html +++ b/src/app/xtream/xtream-main-container.component.html @@ -8,6 +8,7 @@ [searchVisible]=" currentLayout === 'category' || currentLayout === 'category_content' " + [contentTypeNavigationItems]="navigationContentTypes" />
@@ -49,7 +50,5 @@ [items]="favorites$ | async" (itemClicked)="itemClicked($event)" /> - -
diff --git a/src/app/xtream/xtream-main-container.component.scss b/src/app/xtream/xtream-main-container.component.scss index 9c3468de1..a17abe983 100644 --- a/src/app/xtream/xtream-main-container.component.scss +++ b/src/app/xtream/xtream-main-container.component.scss @@ -1,8 +1,6 @@ .container { height: calc(100vh - 140px); overflow-y: auto; - display: flex; - justify-content: center; } mat-spinner { diff --git a/src/app/xtream/xtream-main-container.component.ts b/src/app/xtream/xtream-main-container.component.ts index 8edb4343e..97a5b56ee 100644 --- a/src/app/xtream/xtream-main-container.component.ts +++ b/src/app/xtream/xtream-main-container.component.ts @@ -25,6 +25,7 @@ import { IpcCommand } from '../../../shared/ipc-command.class'; import { ERROR, OPEN_MPV_PLAYER, + OPEN_VLC_PLAYER, XTREAM_REQUEST, XTREAM_RESPONSE, } from '../../../shared/ipc-commands'; @@ -39,7 +40,6 @@ import { CategoryContentViewComponent } from './category-content-view/category-c import { CategoryViewComponent } from './category-view/category-view.component'; import { ContentType } from './content-type.enum'; import { NavigationBarComponent } from './navigation-bar/navigation-bar.component'; -import { PlayerViewComponent } from './player-view/player-view.component'; import { VodDetailsComponent } from './vod-details/vod-details.component'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -53,26 +53,25 @@ import { } from '../../../shared/xtream-serie-details.interface'; import { PlaylistsService } from '../services/playlists.service'; import { Settings, VideoPlayer } from '../settings/settings.interface'; +import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component'; import { STORE_KEY } from '../shared/enums/store-keys.enum'; -import { Breadcrumb } from './breadcrumb.interface'; +import { Breadcrumb, PortalActions } from './breadcrumb.interface'; +import { ContentTypeNavigationItem } from './content-type-navigation-item.interface'; import { PlayerDialogComponent } from './player-dialog/player-dialog.component'; import { PortalStore } from './portal.store'; import { SerialDetailsComponent } from './serial-details/serial-details.component'; const ContentTypes = { - LIVE_STREAMS: { - title: 'Live streams', + [ContentType.ITV]: { getContentAction: XtreamCodeActions.GetLiveStreams, getCategoryAction: XtreamCodeActions.GetLiveCategories, }, - VODS: { - title: 'VOD streams', + [ContentType.VODS]: { getContentAction: XtreamCodeActions.GetVodStreams, getCategoryAction: XtreamCodeActions.GetVodCategories, getDetailsAction: XtreamCodeActions.GetVodInfo, }, - SERIES: { - title: 'Series', + [ContentType.SERIES]: { getContentAction: XtreamCodeActions.GetSeries, getCategoryAction: XtreamCodeActions.GetSeriesCategories, getDetailsAction: XtreamCodeActions.GetSeriesInfo, @@ -105,12 +104,12 @@ type LayoutView = MatIconModule, NavigationBarComponent, VodDetailsComponent, - PlayerViewComponent, CategoryContentViewComponent, SerialDetailsComponent, PlayerDialogComponent, MatProgressSpinnerModule, AsyncPipe, + ExternalPlayerInfoDialogComponent, ], }) export class XtreamMainContainerComponent implements OnInit { @@ -123,6 +122,20 @@ export class XtreamMainContainerComponent implements OnInit { storage = inject(StorageMap); store = inject(Store); currentPlaylist = this.store.selectSignal(selectCurrentPlaylist); + navigationContentTypes: ContentTypeNavigationItem[] = [ + { + contentType: ContentType.ITV, + label: 'Live Streams', + }, + { + contentType: ContentType.VODS, + label: 'VOD Streams', + }, + { + contentType: ContentType.SERIES, + label: 'Series', + }, + ]; favorites$: Observable; breadcrumbs: Breadcrumb[] = []; @@ -151,16 +164,15 @@ export class XtreamMainContainerComponent implements OnInit { effect(() => { if (this.currentPlaylist()) { this.getCategories(this.selectedContentType); + this.favorites$ = this.playlistService.getPortalFavorites( + this.currentPlaylist()._id + ); } }); } ngOnInit() { this.setInitialBreadcrumb(); - console.log(this.currentPlaylist()._id); - this.favorites$ = this.playlistService.getPortalFavorites( - this.currentPlaylist()._id - ); this.commandsList.forEach((command) => { if (this.dataService.isElectron) { @@ -228,6 +240,7 @@ export class XtreamMainContainerComponent implements OnInit { categoryClicked(item: XtreamCategory) { this.items = []; + this.portalStore.setSearchPhrase(''); const action = ContentTypes[this.selectedContentType].getContentAction; this.breadcrumbs.push({ title: item.category_name, @@ -243,19 +256,16 @@ export class XtreamMainContainerComponent implements OnInit { ) { let action; - this.items = []; if (item.stream_type && item.stream_type === 'movie') { + this.items = []; action = XtreamCodeActions.GetVodInfo; this.breadcrumbs.push({ title: item.name, action }); this.contentId = item.stream_id; this.sendRequest({ action, vod_id: item.stream_id }); } else if (item.stream_type && item.stream_type === 'live') { - this.breadcrumbs.push({ - title: item.name, - action: XtreamCodeActions.GetLiveStreams, - }); this.playLiveStream(item); } else if (item.series_id) { + this.items = []; action = XtreamCodeActions.GetSeriesInfo; this.breadcrumbs.push({ title: item.name, action }); this.contentId = item.series_id; @@ -272,10 +282,15 @@ export class XtreamMainContainerComponent implements OnInit { openPlayer(streamUrl: string, title: string) { const player = this.settings().player; if (player === VideoPlayer.MPV) { - this.currentLayout = 'player'; + this.dialog.open(ExternalPlayerInfoDialogComponent); this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl, }); + } else if (player === VideoPlayer.VLC) { + this.dialog.open(ExternalPlayerInfoDialogComponent); + this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { + url: streamUrl, + }); } else { this.dialog.open(PlayerDialogComponent, { data: { streamUrl, player, title }, @@ -346,7 +361,7 @@ export class XtreamMainContainerComponent implements OnInit { }); } - getLayoutViewBasedOnAction(action: XtreamCodeActions) { + getLayoutViewBasedOnAction(action: PortalActions) { let result: LayoutView = 'category'; switch (action) { case XtreamCodeActions.GetLiveCategories: @@ -385,7 +400,6 @@ export class XtreamMainContainerComponent implements OnInit { } setSearchPhrase(searchPhrase: string) { - console.log(searchPhrase); this.searchPhrase = searchPhrase; } diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index bf557dd7d..969c894a3 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -10,7 +10,8 @@ "UNGROUPED": "Ohne Gruppe", "UNNAMED_CHANNEL": "Sender ohne Namen", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen", - "USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren" + "USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren", + "FAVORITES_UPDATED": "Favoriten wurden aktualisiert!" }, "EPG": { "EPG_NOT_AVAILABLE_CHANNEL_DESCRIPTION": "Versuchen Sie bitte die TV-Guide Einstellungen zu ändern.", @@ -51,6 +52,7 @@ "NO_PLAYLISTS": "Noch keine Playlisten hinzugefügt", "REMOVE": "Playlist entfernen", "SHOW_DETAILS": "Details anzeigen", + "SEARCH_PLAYLISTS": "Suche nach Playlisten", "INFO_DIALOG": { "CHANNELS": "Anzahl von Sender", "CLOSE": "Schließen", @@ -66,13 +68,17 @@ "AUTO_UPDATE": "Auto-update", "AUTO_UPDATE_DESCRIPTION": "Wenn die Auto-Refresh-Funktion aktiviert ist, wird die Wiedergabeliste bei jedem Start der Anwendung automatisch aktualisiert.", "CUSTOM_USER_AGENT": "Einige IPTV-Anbieter benötigen einen bestimmten 'User-Agent', um ihre Playlist abspielen zu können.", - "EXPORT_PLAYLIST": "Als m3u exportieren" + "EXPORT_PLAYLIST": "Als m3u exportieren", + "PASSWORD": "Passwort", + "SERVER_URL": "Server-URL", + "USERNAME": "Nutzername", + "MAC_ADDRESS": "MAC-Adresse" }, "UPDATED": "Aktualisiert am", "REFRESH": "Aktualisieren", "REMOVE_DIALOG": { "MESSAGE": "Sind Sie sicher, dass Sie diese Wiedergabeliste vollständig löschen möchten", - "TITLE": "" + "TITLE": "Wiedergabeliste entfernen" }, "GLOBAL_FAVORITES": "Globale Favoriten", "GLOBAL_FAVORITES_DESCRIPTION": "Auto-generated playlist with aggregated favorites from all playlists", @@ -100,6 +106,10 @@ "PASSWORD": "Passwort", "ADD": "Hinzufügen", "TEST_CREDENTIALS": "Verbindung testen" + }, + "STALKER_PORTAL": { + "ADD": "Hinzufügen", + "TITLE": "Titel" } }, "LANGUAGES": { @@ -130,7 +140,15 @@ "VIDEO_PLAYER_PLACEHOLDER": "Option auswählen", "SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!", "THEME": "Farbdesign", - "SHOW_CAPTIONS": "Untertitel anzeigen" + "SHOW_CAPTIONS": "Untertitel anzeigen", + "REMOVE_ALL": "Playlist entfernen", + "REMOVE_ALL_BUTTON": "Entfernen", + "REMOVE_DIALOG": { + "TITLE": "Аlle Wiedergabelisten löschen" + }, + "LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus", + "PLAYLISTS_REMOVED": "Alle Playlists wurden entfernt.", + "VERSION_DESCRIPTION": "Aktuelle Version der Anwendung" }, "THEMES": { "DARK_THEME": "Dunkel", @@ -153,5 +171,30 @@ "WHAT_IS_NEW": "Was ist neu", "ABOUT": "Über", "ABOUT_ARIA": "Über die Anwendung" - } + }, + "XTREAM": { + "RELEASE_DATE": "Veröffentlichungsdatum", + "ACTORS": "Schauspieler", + "PLAY": "Abspielen", + "GENRE": "Genre", + "DURATION": "Dauer", + "COUNTRY": "Land", + "NAME": "Name", + "DIRECTOR": "Regisseur", + "CAST": "Gießen", + "RATING": "Bewertung" + }, + "PORTALS": { + "ALL_CATEGORIES": "Alle Kategorien", + "ADD_TO_FAVORITES": "Zu den Favoriten hinzufügen", + "REMOVE_FROM_FAVORITES": "Von Favoriten entfernen", + "MY_FAVORITES": "Meine Favoriten" + }, + "ABOUT": { + "TITLE": "Über IPTVnator", + "VERSION": "Ausführung", + "TWITTER_TOOLTIP": "Auf Twitter teilen" + }, + "REFRESH": "Aktualisieren", + "UPDATE_AVAILABLE": "Update verfügbar" } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b5f4c3d38..5b2c82b4d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -8,7 +8,8 @@ "FILE_UPLOAD": "Add via file upload", "URL_UPLOAD": "Add via URL", "TEXT_IMPORT": "Import as text", - "XTREME_IMPORT": "Import xtreme code" + "XTREME_IMPORT": "Add Xtreme Code", + "STALKER_PORTAL_IMPORT": "Add Stalker Portal" }, "PLAYLISTS": { "NO_PLAYLISTS": "No playlists were added", @@ -16,10 +17,12 @@ "ADDED_VIA_URL": "Added via URL upload. URL:", "ADDED_VIA_FILE": "Added via file upload", "XTREAM_PLAYLIST": "Xtream playlist", + "STALKER_PORTAL": "Stalker portal", "CHANNELS": "Channels", "ADDED": "Added", "SHOW_DETAILS": "Show playlist details", "REMOVE": "Remove playlist", + "SEARCH_PLAYLISTS": "Search playlists by title", "INFO_DIALOG": { "PLAYLIST_DETAILS": "Playlist details", "TITLE": "Title", @@ -38,7 +41,8 @@ "EXPORT_PLAYLIST": "Export playlist as m3u", "SERVER_URL": "Server URL", "USERNAME": "Username", - "PASSWORD": "Password" + "PASSWORD": "Password", + "MAC_ADDRESS": "Mac Address" }, "REMOVE_DIALOG": { "TITLE": "Remove playlist", @@ -75,6 +79,13 @@ "ADD": "Add", "TEST_CREDENTIALS": "Test credentials", "URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com or https://example.com:4324)" + }, + "STALKER_PORTAL": { + "TITLE": "Title", + "MAC_ADDRESS": "Mac Address", + "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" } }, "SETTINGS": { @@ -119,7 +130,10 @@ "PLAYLISTS_REMOVED": "All playlists were removed.", "MPV_PLAYER_PATH_LABEL": "mpv path", "MPV_PLAYER_PATH_DESCRIPTION": "Set path to mpv player in your system. It will use the provided path to a mpv binary instead of using the one found in $PATH.", - "MPV_PLAYER_PATH": "mpv player path" + "MPV_PLAYER_PATH": "mpv player path", + "VLC_PLAYER_PATH_LABEL": "VLC path", + "VLC_PLAYER_PATH_DESCRIPTION": "Set path to VLC player in your system. It will use the provided path to a VLC binary instead of the default path for our system.", + "VLC_PLAYER_PATH": "VLC player path" }, "THEMES": { "DARK_THEME": "Dark theme", @@ -211,5 +225,11 @@ "CAST": "Cast", "EPISODE_RUN_TIME": "Episode run time", "RATING": "Rating" + }, + "PORTALS": { + "ALL_CATEGORIES": "All categories", + "MY_FAVORITES": "My favorites", + "ADD_TO_FAVORITES": "Add to favorites", + "REMOVE_FROM_FAVORITES": "Remove from favorites" } } diff --git a/src/assets/i18n/es.json b/src/assets/i18n/es.json index 0a03a92e6..ac2cba7aa 100644 --- a/src/assets/i18n/es.json +++ b/src/assets/i18n/es.json @@ -32,7 +32,11 @@ "UPDATE_FAILED": "Última actualización de la lista fallida", "AUTO_UPDATE": "Auto-actualizar", "AUTO_UPDATE_DESCRIPTION": "Si la función de auto-actualizar se activa, la lista de reproducción se refrescará automáticamente cada vez que se inicia la app.", - "CUSTOM_USER_AGENT": "Algunos proveedores de IPTV necesitan especificar el agente de usuario para reproducir su lista de reproducción." + "CUSTOM_USER_AGENT": "Algunos proveedores de IPTV necesitan especificar el agente de usuario para reproducir su lista de reproducción.", + "PASSWORD": "Contraseña", + "SERVER_URL": "URL del servidor", + "USERNAME": "Nombre de usuario", + "MAC_ADDRESS": "Dirección MAC" }, "REMOVE_DIALOG": { "TITLE": "Quitar lista de reproducción", @@ -56,6 +60,10 @@ "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", "BUTTON_LABEL": "Import" + }, + "STALKER_PORTAL": { + "ADD": "Agregar", + "TITLE": "Título" } }, "SETTINGS": { @@ -75,7 +83,15 @@ "LANGUAGE": "Idioma", "SETTINGS_SAVED": "¡Completado! La configuración se guardó.", "THEME": "Tema visual", - "SHOW_CAPTIONS": "Mostrar subtítulos" + "SHOW_CAPTIONS": "Mostrar subtítulos", + "REMOVE_ALL": "Eliminar lista de reproducción", + "REMOVE_ALL_BUTTON": "Eliminar", + "REMOVE_DIALOG": { + "TITLE": "Eliminar todas las listas de reproducción" + }, + "LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación", + "PLAYLISTS_REMOVED": "Se eliminaron todas las listas de reproducción.", + "VERSION_DESCRIPTION": "Versión actual de la aplicación." }, "THEMES": { "DARK_THEME": "Tema oscuro", @@ -91,7 +107,8 @@ "FAVORITES": "Favoritos", "NO_FAVORITES": "Sin favoritos", "USE_STAR_TO_FAVORITE": "Por favor, usa el ícono de la 'estrella' para añadir canales a la lista.", - "REMOVE_FAVORITE": "Quitar de favoritos" + "REMOVE_FAVORITE": "Quitar de favoritos", + "FAVORITES_UPDATED": "¡Se actualizaron los favoritos!" }, "TOP_MENU": { "OPEN_CHANNELS_LIST": "Open channels list", @@ -143,5 +160,30 @@ "WHAT_IS_NEW": "What is new", "ABOUT": "About", "ABOUT_ARIA": "About the application" - } + }, + "XTREAM": { + "RELEASE_DATE": "Fecha de lanzamiento", + "ACTORS": "actores", + "PLAY": "Jugar", + "GENRE": "Género", + "DURATION": "Duración", + "COUNTRY": "País", + "NAME": "Nombre", + "DIRECTOR": "Director", + "CAST": "Elenco", + "RATING": "Clasificación" + }, + "PORTALS": { + "ALL_CATEGORIES": "Todas las categorias", + "ADD_TO_FAVORITES": "Agregar a los favoritos", + "REMOVE_FROM_FAVORITES": "Quitar de favoritos", + "MY_FAVORITES": "Mis favoritos" + }, + "ABOUT": { + "TITLE": "Acerca de IPTVnator", + "VERSION": "Versión", + "TWITTER_TOOLTIP": "Compartir en Twitter" + }, + "REFRESH": "Actualizar", + "UPDATE_AVAILABLE": "Actualización disponible" } diff --git a/src/assets/i18n/fr.json b/src/assets/i18n/fr.json index 1f8ee13b4..401612d0c 100644 --- a/src/assets/i18n/fr.json +++ b/src/assets/i18n/fr.json @@ -32,7 +32,11 @@ "UPDATE_FAILED": "Échec de la dernière mise à jour de liste de lecture", "AUTO_UPDATE": "Mise à jour automatique", "AUTO_UPDATE_DESCRIPTION": "Si la fonction de mise à jour automatique est activée, la liste de lecture sera mise à jour automatiquement à chaque fois que l'app est lancée.", - "CUSTOM_USER_AGENT": "Certains fournisseurs d'IPTC requiert un agent utilisateur spécifique afin de lire leur liste de lecture." + "CUSTOM_USER_AGENT": "Certains fournisseurs d'IPTC requiert un agent utilisateur spécifique afin de lire leur liste de lecture.", + "PASSWORD": "Mot de passe", + "SERVER_URL": "URL du serveur", + "USERNAME": "Nom d'utilisateur", + "MAC_ADDRESS": "Adresse Mac" }, "REMOVE_DIALOG": { "TITLE": "Retirer la liste de lecture", @@ -56,6 +60,10 @@ "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", "BUTTON_LABEL": "Import" + }, + "STALKER_PORTAL": { + "ADD": "Ajouter", + "TITLE": "Titre" } }, "SETTINGS": { @@ -75,7 +83,15 @@ "LANGUAGE": "Langue", "SETTINGS_SAVED": "La configuration a été enregistrée avec succès.", "THEME": "Thème", - "SHOW_CAPTIONS": "Afficher les sous-titres" + "SHOW_CAPTIONS": "Afficher les sous-titres", + "REMOVE_ALL": "Supprimer la liste de lecture", + "REMOVE_ALL_BUTTON": "Retirer", + "REMOVE_DIALOG": { + "TITLE": "Supprimer toutes les playlists" + }, + "LANGUAGE_DESCRIPTION": "Sélectionnez la langue de l'application", + "PLAYLISTS_REMOVED": "Toutes les listes de lecture ont été supprimées.", + "VERSION_DESCRIPTION": "Version actuelle de l'application" }, "THEMES": { "DARK_THEME": "Thème foncé", @@ -91,7 +107,8 @@ "FAVORITES": "Favoris", "NO_FAVORITES": "Aucun favoris", "USE_STAR_TO_FAVORITE": "Veuillez utiliser l'icône 'étoile' pour ajouter une chaine aux favoris.", - "REMOVE_FAVORITE": "Retirer des favoris" + "REMOVE_FAVORITE": "Retirer des favoris", + "FAVORITES_UPDATED": "Les favoris ont été mis à jour !" }, "TOP_MENU": { "OPEN_CHANNELS_LIST": "Ouvrir la liste des chaines", @@ -143,5 +160,30 @@ "WHAT_IS_NEW": "What is new", "ABOUT": "About", "ABOUT_ARIA": "About the application" - } + }, + "XTREAM": { + "RELEASE_DATE": "Date de sortie", + "ACTORS": "Acteurs", + "PLAY": "Jouer", + "GENRE": "Genre", + "DURATION": "Durée", + "COUNTRY": "Pays", + "NAME": "Nom", + "DIRECTOR": "Directeur", + "CAST": "Casting", + "RATING": "Notation" + }, + "PORTALS": { + "ALL_CATEGORIES": "Toutes catégories", + "ADD_TO_FAVORITES": "Ajouter aux Favoris", + "REMOVE_FROM_FAVORITES": "Retirer des favoris", + "MY_FAVORITES": "Mes favoris" + }, + "ABOUT": { + "TITLE": "À propos d'IPTVnator", + "VERSION": "Version", + "TWITTER_TOOLTIP": "Partager sur Twitter" + }, + "REFRESH": "Rafraîchir", + "UPDATE_AVAILABLE": "Mise à jour disponible" } diff --git a/src/assets/i18n/it.json b/src/assets/i18n/it.json index a38c267ef..478165d86 100644 --- a/src/assets/i18n/it.json +++ b/src/assets/i18n/it.json @@ -8,7 +8,8 @@ "FILE_UPLOAD": "Aggiungi tramite caricamento file", "URL_UPLOAD": "Aggiungi tramite URL", "TEXT_IMPORT": "Importa come testo", - "XTREME_IMPORT": "Importa codice Xtreme" + "XTREME_IMPORT": "Aggiungi codice Xtreme", + "STALKER_PORTAL_IMPORT": "Aggiungi portale Stalker" }, "PLAYLISTS": { "NO_PLAYLISTS": "Nessuna playlist è stata aggiunta", @@ -16,10 +17,12 @@ "ADDED_VIA_URL": "Aggiunto tramite caricamento URL. URL:", "ADDED_VIA_FILE": "Aggiunto tramite caricamento file", "XTREAM_PLAYLIST": "Playlist Xtream", + "STALKER_PORTAL": "Portale Stalker", "CHANNELS": "Canali", "ADDED": "Aggiunto", "SHOW_DETAILS": "Visualizza i dettagli della playlist", "REMOVE": "Rimuovi playlist", + "SEARCH_PLAYLISTS": "Cerca playlist per titolo", "INFO_DIALOG": { "PLAYLIST_DETAILS": "Dettagli playlist", "TITLE": "Titolo", @@ -38,7 +41,8 @@ "EXPORT_PLAYLIST": "Esporta playlist come file m3u", "SERVER_URL": "URL server", "USERNAME": "Utente", - "PASSWORD": "Password" + "PASSWORD": "Password", + "MAC_ADDRESS": "Indirizzo MAC" }, "REMOVE_DIALOG": { "TITLE": "Rimuovi playlist", @@ -75,6 +79,13 @@ "ADD": "Aggiungi", "TEST_CREDENTIALS": "Testa credenziali", "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it o https://esempio.it:4324)" + }, + "STALKER_PORTAL": { + "TITLE": "Titolo", + "MAC_ADDRESS": "Indirizzo MAC", + "SERVER_URL": "URL server", + "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it/c o https://esempio.it/stalker_portal/c)", + "ADD": "Aggiungi" } }, "SETTINGS": { @@ -119,7 +130,10 @@ "PLAYLISTS_REMOVED": "Tutte le playlist sono state rimosse.", "MPV_PLAYER_PATH_LABEL": "percorso mpv", "MPV_PLAYER_PATH_DESCRIPTION": "Imposta il percorso per il lettore mpv in questo tuo sistema. Verrà usato il percorso fornito per un eseguibile mpv invece di usare quello trovato in $PATH.", - "MPV_PLAYER_PATH": "percorso riproduttore mpv" + "MPV_PLAYER_PATH": "percorso riproduttore mpv", + "VLC_PLAYER_PATH_LABEL": "Percorso VLC", + "VLC_PLAYER_PATH_DESCRIPTION": "Imposta il percorso del lettore VLC nel sistema. Verrà usato il percorso fornito per il file exe VLC invece del percorso predefinito per il sistema.", + "VLC_PLAYER_PATH": "Percorso lettore VLC" }, "THEMES": { "DARK_THEME": "Tema scuro", @@ -211,5 +225,11 @@ "CAST": "Cast", "EPISODE_RUN_TIME": "Tempo esecuzione episodio", "RATING": "Classifica" + }, + "PORTALS": { + "ALL_CATEGORIES": "Tutte le categorie", + "MY_FAVORITES": "Preferiti", + "ADD_TO_FAVORITES": "Aggiungi ai preferiti", + "REMOVE_FROM_FAVORITES": "Rimuovi dai preferiti" } } diff --git a/src/assets/i18n/ko.json b/src/assets/i18n/ko.json index 27cd6faab..8aa91040c 100644 --- a/src/assets/i18n/ko.json +++ b/src/assets/i18n/ko.json @@ -32,7 +32,11 @@ "UPDATE_FAILED": "마지막 재생 목록 업데이트가 실패했습니다", "AUTO_UPDATE": "자동 업데이트", "AUTO_UPDATE_DESCRIPTION": "자동 새로 고침 기능이 활성화되면 앱이 시작될 때마다 재생 목록이 자동으로 업데이트됩니다.", - "CUSTOM_USER_AGENT": "일부 IPTV 공급자는 재생 목록을 재생하기 위해 특정 사용자 에이전트가 필요합니다." + "CUSTOM_USER_AGENT": "일부 IPTV 공급자는 재생 목록을 재생하기 위해 특정 사용자 에이전트가 필요합니다.", + "PASSWORD": "비밀번호", + "SERVER_URL": "서버 URL", + "USERNAME": "사용자 이름", + "MAC_ADDRESS": "맥 주소" }, "REMOVE_DIALOG": { "TITLE": "재생 목록 제거", @@ -56,6 +60,10 @@ "TEXT_IMPORT": { "LABEL": "Insert m3u(8) playlist as text", "BUTTON_LABEL": "Import" + }, + "STALKER_PORTAL": { + "ADD": "추가하다", + "TITLE": "제목" } }, "SETTINGS": { @@ -75,7 +83,15 @@ "LANGUAGE": "언어", "SETTINGS_SAVED": "설정이 저장되었습니다.", "THEME": "테마", - "SHOW_CAPTIONS": "Show subtitles" + "SHOW_CAPTIONS": "Show subtitles", + "REMOVE_ALL": "재생목록 삭제", + "REMOVE_ALL_BUTTON": "제거하다", + "REMOVE_DIALOG": { + "TITLE": "모든 재생목록 삭제" + }, + "LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택", + "PLAYLISTS_REMOVED": "모든 재생목록이 삭제되었습니다.", + "VERSION_DESCRIPTION": "애플리케이션의 현재 버전" }, "THEMES": { "DARK_THEME": "어두운 테마", @@ -91,7 +107,8 @@ "FAVORITES": "즐겨찾기", "NO_FAVORITES": "즐겨찾기가 아직 없습니다", "USE_STAR_TO_FAVORITE": "'Star'아이콘을 사용하여 이 목록에 채널을 추가하십시오.", - "REMOVE_FAVORITE": "즐겨찾기에서 삭제하기" + "REMOVE_FAVORITE": "즐겨찾기에서 삭제하기", + "FAVORITES_UPDATED": "즐겨찾기가 업데이트되었습니다!" }, "TOP_MENU": { "OPEN_CHANNELS_LIST": "채널 열기 목록", @@ -143,5 +160,30 @@ "WHAT_IS_NEW": "What is new", "ABOUT": "About", "ABOUT_ARIA": "About the application" - } + }, + "XTREAM": { + "RELEASE_DATE": "출시일", + "ACTORS": "배우", + "PLAY": "놀다", + "GENRE": "장르", + "DURATION": "지속", + "COUNTRY": "국가", + "NAME": "이름", + "DIRECTOR": "감독", + "CAST": "깁스", + "RATING": "평가" + }, + "PORTALS": { + "ALL_CATEGORIES": "모든 카테고리", + "ADD_TO_FAVORITES": "즐겨 찾기에 추가", + "REMOVE_FROM_FAVORITES": "즐겨 찾기에서 삭제하기", + "MY_FAVORITES": "내가 좋아하는 것들" + }, + "ABOUT": { + "TITLE": "IPTVnator 소개", + "VERSION": "버전", + "TWITTER_TOOLTIP": "트위터에 공유" + }, + "REFRESH": "새로 고치다", + "UPDATE_AVAILABLE": "업데이트 가능" } diff --git a/src/assets/i18n/ru.json b/src/assets/i18n/ru.json index c3ab918f4..e877fd310 100644 --- a/src/assets/i18n/ru.json +++ b/src/assets/i18n/ru.json @@ -7,7 +7,9 @@ "RECENTLY_ADDED": "Недавно добавленные плейлисты", "FILE_UPLOAD": "Загрузить с компьютера", "URL_UPLOAD": "Загрузить по ссылке", - "TEXT_IMPORT": "Импортировать из текста" + "TEXT_IMPORT": "Импортировать из текста", + "STALKER_PORTAL_IMPORT": "Добавить Stalker портал", + "XTREME_IMPORT": "Добавить Xtreme Code " }, "PLAYLISTS": { "NO_PLAYLISTS": "Пока еще не было добавлено ни одного плейлиста", @@ -18,6 +20,7 @@ "ADDED": "Добавлен", "SHOW_DETAILS": "Показать информацию о плейлисте", "REMOVE": "Удалить плейлист", + "SEARCH_PLAYLISTS": "Поиск по названию плейлиста", "INFO_DIALOG": { "PLAYLIST_DETAILS": "Подробная информация о плейлисте", "TITLE": "Название", @@ -33,7 +36,11 @@ "AUTO_UPDATE": "Автообновление", "AUTO_UPDATE_DESCRIPTION": "Если активирована функция автообновления, список воспроизведения будет обновляться автоматически при каждом запуске приложения.", "CUSTOM_USER_AGENT": "Некоторые IPTV провайдеры требуют указание определенного пользовательского агента (User Agent) для воспроизведения своего плейлиста.", - "EXPORT_PLAYLIST": "Экспортировать плейлист (m3u)" + "EXPORT_PLAYLIST": "Экспортировать плейлист (m3u)", + "PASSWORD": "Пароль", + "SERVER_URL": "URL-адрес сервера", + "USERNAME": "Имя пользователя", + "MAC_ADDRESS": "MAC-адрес" }, "UPDATED": "Обновлен", "REFRESH": "Обновить плейлист", @@ -45,7 +52,9 @@ "GLOBAL_FAVORITES_DESCRIPTION": "Автоматически сгенерированный плейлист, включающий в себя фавориты из всех плейлистов", "MY_PLAYLISTS": "Мои плейлисты", "MY_PLAYLISTS_SUBTITLE": "Все имеющиеся плейлисты", - "PLAYLIST_UPDATE_SUCCESS": "Плейлист был успешно обновлен!" + "PLAYLIST_UPDATE_SUCCESS": "Плейлист был успешно обновлен!", + "STALKER_PORTAL": "Stalker портал", + "XTREAM_PLAYLIST": "Xtream плейлист" }, "FILE_UPLOAD": { "DRAG_DROP": "Перетащите файл в эту область", @@ -68,6 +77,12 @@ "PASSWORD": "Пароль", "ADD": "Добавить", "TEST_CREDENTIALS": "Проверить" + }, + "STALKER_PORTAL": { + "ADD": "Добавить", + "TITLE": "Название", + "MAC_ADDRESS": "MAC-адрес", + "SERVER_URL": "URL сервера" } }, "SETTINGS": { @@ -101,7 +116,7 @@ "IMPORT_EXPORT_DATA_DESCRIPTION": "Export or import playlists in the JSON format used within the application.", "IMPORT_DATA": "Импорт", "EXPORT_DATA": "Экспорт", - "REMOVE_ALL": "Remove playlist", + "REMOVE_ALL": "Удалить плейлист", "REMOVE_ALL_BUTTON": "Удалить", "REMOVE_ALL_DESCRIPTION": "It will remove all existing playlists from the application.", "REMOVE_DIALOG": { @@ -109,7 +124,11 @@ "MESSAGE": "Вы уверены что хотите удалить все плейлисты из приложения?" }, "IMPORT_ERROR": "Import error, please try with another file.", - "PLAYLISTS_REMOVED": "All playlists were removed." + "PLAYLISTS_REMOVED": "Все плейлисты были удалены.", + "VLC_PLAYER_PATH_LABEL": "Путь к VLC плееру", + "VLC_PLAYER_PATH": "Путь к VLC плееру", + "MPV_PLAYER_PATH_LABEL": "Путь к mpv плееру", + "MPV_PLAYER_PATH": "Путь к mpv плееру" }, "THEMES": { "DARK_THEME": "Тёмная тема", @@ -188,5 +207,24 @@ "TWITTER_TOOLTIP": "Поделиться на Twitter" }, "UPDATE_AVAILABLE": "Доступно обновление", - "REFRESH": "Обновить" + "REFRESH": "Обновить", + "XTREAM": { + "RATING": "Рейтинг", + "RELEASE_DATE": "Дата выпуска", + "ACTORS": "Актеры", + "PLAY": "Воспроизвести", + "GENRE": "Жанр", + "DURATION": "Продолжительность", + "COUNTRY": "Страна", + "NAME": "Название", + "DIRECTOR": "Режиссёр", + "CAST": "Бросать", + "EPISODE_RUN_TIME": "Длительность эпизода" + }, + "PORTALS": { + "ALL_CATEGORIES": "Все категории", + "ADD_TO_FAVORITES": "Добавить в избранное", + "REMOVE_FROM_FAVORITES": "Удалить из избранного", + "MY_FAVORITES": "Избранное" + } } diff --git a/src/assets/i18n/zh.json b/src/assets/i18n/zh.json index 393bf899d..10b6cf003 100644 --- a/src/assets/i18n/zh.json +++ b/src/assets/i18n/zh.json @@ -32,7 +32,11 @@ "UPDATE_FAILED": "上次播放列表更新失败", "AUTO_UPDATE": "自动更新", "AUTO_UPDATE_DESCRIPTION": "如果启用了自动刷新功能,则每次启动应用程序时都会自动更新播放列表。", - "CUSTOM_USER_AGENT": "一些 IPTV 提供商需要特定的用户代理才能播放他们的播放列表。" + "CUSTOM_USER_AGENT": "一些 IPTV 提供商需要特定的用户代理才能播放他们的播放列表。", + "PASSWORD": "密码", + "SERVER_URL": "服务器地址", + "USERNAME": "用户名", + "MAC_ADDRESS": "MAC地址" }, "REMOVE_DIALOG": { "TITLE": "移除播放列表", @@ -56,6 +60,10 @@ "TEXT_IMPORT": { "LABEL": "从文本导入播放列表(m3u, m3u8)", "BUTTON_LABEL": "导入" + }, + "STALKER_PORTAL": { + "ADD": "添加", + "TITLE": "标题" } }, "SETTINGS": { @@ -75,7 +83,15 @@ "LANGUAGE": "语言", "SETTINGS_SAVED": "配置已成功保存", "THEME": "主题", - "SHOW_CAPTIONS": "显示字幕" + "SHOW_CAPTIONS": "显示字幕", + "REMOVE_ALL": "删除播放列表", + "REMOVE_ALL_BUTTON": "消除", + "REMOVE_DIALOG": { + "TITLE": "删除所有播放列表" + }, + "LANGUAGE_DESCRIPTION": "选择应用程序的语言", + "PLAYLISTS_REMOVED": "所有播放列表均已删除。", + "VERSION_DESCRIPTION": "应用程序的当前版本" }, "THEMES": { "DARK_THEME": "暗黑模式", @@ -91,7 +107,8 @@ "FAVORITES": "收藏", "NO_FAVORITES": "还没有收藏夹", "USE_STAR_TO_FAVORITE": "使用“☆”图标将频道添加到此列表中", - "REMOVE_FAVORITE": "从收藏夹中删除" + "REMOVE_FAVORITE": "从收藏夹中删除", + "FAVORITES_UPDATED": "收藏夹已更新!" }, "TOP_MENU": { "OPEN_CHANNELS_LIST": "打开频道列表", @@ -143,5 +160,30 @@ "WHAT_IS_NEW": "更新日志", "ABOUT": "关于", "ABOUT_ARIA": "关于应用" - } + }, + "XTREAM": { + "RELEASE_DATE": "发布日期", + "ACTORS": "演员", + "PLAY": "玩", + "GENRE": "类型", + "DURATION": "期间", + "COUNTRY": "国家", + "NAME": "姓名", + "DIRECTOR": "导演", + "CAST": "投掷", + "RATING": "评分" + }, + "PORTALS": { + "ALL_CATEGORIES": "所有类别", + "ADD_TO_FAVORITES": "添加到收藏夹", + "REMOVE_FROM_FAVORITES": "从收藏夹中删除", + "MY_FAVORITES": "我最喜欢的" + }, + "ABOUT": { + "TITLE": "关于 IPTVnator", + "VERSION": "版本", + "TWITTER_TOOLTIP": "分享到Twitter" + }, + "REFRESH": "刷新", + "UPDATE_AVAILABLE": "可用更新" } diff --git a/src/assets/i18n/zhtw.json b/src/assets/i18n/zhtw.json index 472e6c373..bf811fb9b 100644 --- a/src/assets/i18n/zhtw.json +++ b/src/assets/i18n/zhtw.json @@ -33,7 +33,11 @@ "AUTO_UPDATE": "自動更新", "AUTO_UPDATE_DESCRIPTION": "如果啟動自動重新整理功能,則每次啟動應用程式時都將自動更新播放清單。", "CUSTOM_USER_AGENT": "部分 IPTV 服務提供者需要特定的 User-Agent 才能播放他們的播放清單。", - "EXPORT_PLAYLIST": "以 m3u 格式匯出播放清單" + "EXPORT_PLAYLIST": "以 m3u 格式匯出播放清單", + "PASSWORD": "Password", + "SERVER_URL": "Server URL", + "USERNAME": "Username", + "MAC_ADDRESS": "Mac Address" }, "REMOVE_DIALOG": { "TITLE": "移除播放清單", @@ -61,6 +65,10 @@ "TEXT_IMPORT": { "LABEL": "將 m3u(8)播放清單以純文字的方式插入", "BUTTON_LABEL": "匯入" + }, + "STALKER_PORTAL": { + "ADD": "Add", + "TITLE": "Title" } }, "SETTINGS": { @@ -181,5 +189,23 @@ "TWITTER_TOOLTIP": "在 Twitter 上分享" }, "UPDATE_AVAILABLE": "有可用的更新", - "REFRESH": "重新整理" + "REFRESH": "重新整理", + "XTREAM": { + "RELEASE_DATE": "Release date", + "ACTORS": "Actors", + "PLAY": "Play", + "GENRE": "Genre", + "DURATION": "Duration", + "COUNTRY": "Country", + "NAME": "Name", + "DIRECTOR": "Director", + "CAST": "Cast", + "RATING": "Rating" + }, + "PORTALS": { + "ALL_CATEGORIES": "All categories", + "ADD_TO_FAVORITES": "Add to favorites", + "REMOVE_FROM_FAVORITES": "Remove from favorites", + "MY_FAVORITES": "My favorites" + } } diff --git a/src/index.html b/src/index.html index e1460df5b..d2475cdec 100644 --- a/src/index.html +++ b/src/index.html @@ -1,4 +1,4 @@ - + @@ -26,7 +26,7 @@ rel="stylesheet" />