Merge branch 'electron'

This commit is contained in:
4gray committed 2023-10-30 17:10:20 +01:00
commit f3ade02d4d
76 files changed
+2292 -466

No files matched your search

+3 -2
View File
@@ -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 . .
+97 -10
View File
@@ -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<string, string> }) => {
const xtreamApiPath = '/player_api.php';
ipcMain
.on(
XTREAM_REQUEST,
(
event,
arg: { url: string; params: Record<string, string> }
) => {
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
+2 -2
View File
@@ -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') {
+139 -47
View File
@@ -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",
+7 -7
View File
@@ -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"
+1 -2
View File
@@ -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;
}
+6
View File
@@ -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';
+2
View File
@@ -32,4 +32,6 @@ export interface Playlist {
serverUrl?: string;
username?: string;
password?: string;
macAddress?: string;
portalUrl?: string;
}
+1 -1
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
export enum StalkerPortalActions {
GetCategories = 'get_categories',
GetGenres = 'get_genres',
CreateLink = 'create_link',
GetOrderedList = 'get_ordered_list',
Favorites = 'favorites',
}
+7
View File
@@ -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: '',
+14 -7
View File
@@ -46,9 +46,7 @@
</div>
</ng-template>
<ng-template matTabContent>
<app-url-upload
(urlAdded)="sendPlaylistsUrl($event)"
/>
<app-url-upload (urlAdded)="sendPlaylistsUrl($event)" />
</ng-template>
</mat-tab>
<mat-tab>
@@ -59,14 +57,12 @@
</div>
</ng-template>
<ng-template matTabContent>
<app-text-import
(textAdded)="uploadAsText($event)"
/>
<app-text-import (textAdded)="uploadAsText($event)" />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">text_format</mat-icon>
<mat-icon class="tab-icon">video_library</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.XTREME_IMPORT' | translate }}
</div>
@@ -75,6 +71,17 @@
<app-xtream-code-import />
</ng-template>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">dashboard</mat-icon>
<div class="tab-title">
{{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }}
</div>
</ng-template>
<ng-template matTabContent>
<app-stalker-portal-import />
</ng-template>
</mat-tab>
</mat-tab-group>
<!-- Loading spinner -->
+2
View File
@@ -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,
@@ -12,6 +12,18 @@
}}</mat-label>
<input formControlName="title" matInput />
</mat-form-field>
<mat-form-field class="full-width" *ngIf="playlist.portalUrl">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="portalUrl" />
</mat-form-field>
<mat-form-field class="full-width" *ngIf="playlist.macAddress">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
}}</mat-label>
<input matInput formControlName="macAddress" />
</mat-form-field>
<mat-form-field class="full-width" *ngIf="playlist.serverUrl">
<mat-label>{{
'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 }}
</button>
@@ -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),
});
}
@@ -14,7 +14,7 @@
<mat-icon
matListItemAvatar
class="upload-type-icon"
*ngIf="!item?.url && !item.serverUrl"
*ngIf="!item?.url && !item.serverUrl && !item.macAddress"
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
>folder</mat-icon
>
@@ -25,9 +25,18 @@
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
>public</mat-icon
>
<mat-icon
matListItemAvatar
class="upload-type-icon"
*ngIf="item?.macAddress"
[matTooltip]="'HOME.PLAYLISTS.STALKER_PORTAL' | translate"
>dashboard</mat-icon
>
<div matListItemTitle>{{ item.title || item.filename }}</div>
<div matListItemLine class="meta">
<ng-container *ngIf="!item.serverUrl; else xtreamPlaylist">
<ng-container
*ngIf="!item.serverUrl && !item.portalUrl; else xtreamPlaylist"
>
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
<ng-container *ngIf="item.importDate?.length > 3">
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
@@ -35,7 +44,7 @@
</ng-container>
<ng-container *ngIf="item.updateDate">
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{ item.updateDate | date : 'MMMM d, yyyy, HH:mm' }}
{{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }}
</ng-container>
<ng-container *ngIf="item.updateState === 2">
<mat-icon
@@ -48,7 +57,7 @@
</ng-container>
</ng-container>
<ng-template #xtreamPlaylist
>Xtream Code Playlist
>Xtream/Stalker Playlist
<ng-container *ngIf="item.importDate?.length > 3">
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date }}
@@ -1,10 +1,24 @@
<ng-container *ngIf="allPlaylistsLoaded$ | async; else skeletonView">
<mat-form-field
class="full-width"
subscriptSizing="dynamic"
style="border-radius: 0px"
>
<input
matInput
#searchQuery
type="search"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
(input)="onSearchQueryUpdate(searchQuery.value)"
/></mat-form-field>
<mat-nav-list
cdkDropList
(cdkDropListDropped)="drop($event, playlists)"
*ngIf="playlists$ | async as playlists"
class="playlists-list"
>
<mat-list-item *ngIf="playlists.length === 0">
<mat-list-item *ngIf="playlists.length === 0; else playlistsTemplate">
<mat-icon class="favorites-icon" matListItemAvatar>search</mat-icon>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
@@ -13,51 +27,56 @@
{{ 'HOME.PLAYLISTS.ADD_FIRST' | translate }}
</div>
</mat-list-item>
<mat-list-item
*ngIf="playlists.length > 0"
(click)="getGlobalFavorites()"
>
<mat-icon class="favorites-icon" matListItemAvatar>star</mat-icon>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div matListItemLine class="meta">
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION' | translate }}
</div>
</mat-list-item>
<mat-divider></mat-divider>
<app-playlist-item
*ngFor="let item of playlists; last as last"
[item]="item"
[showActions]="!sidebarMode"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
></app-playlist-item>
<ng-template #playlistsTemplate>
<mat-list-item (click)="getGlobalFavorites()">
<mat-icon class="favorites-icon" matListItemAvatar
>star</mat-icon
>
<div matListItemTitle>
{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}
</div>
<div matListItemLine class="meta">
{{
'HOME.PLAYLISTS.GLOBAL_FAVORITES_DESCRIPTION'
| translate
}}
</div>
</mat-list-item>
<mat-divider></mat-divider>
<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
Delete all playlists
</button>
</div>
<app-playlist-item
*ngFor="let item of playlists; last as last; trackBy: trackByFn"
[item]="item"
[showActions]="!sidebarMode"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
></app-playlist-item>
<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
{{ 'SETTINGS.REMOVE_DIALOG.TITLE' | translate }}
</button>
</div>
</ng-template>
</mat-nav-list>
</ng-container>
@@ -2,6 +2,10 @@
padding-top: 0;
}
.playlists-list {
padding: 0;
}
.meta {
font-size: 12px !important;
color: #666;
@@ -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);
}
}
@@ -0,0 +1,54 @@
<form [formGroup]="form">
<mat-form-field class="full-width">
<mat-label for="title">{{
'HOME.STALKER_PORTAL.TITLE' | translate
}}</mat-label>
<input matInput type="text" id="title" formControlName="title" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="portalUrl">{{
'HOME.STALKER_PORTAL.SERVER_URL' | translate
}}</mat-label>
<input
matInput
type="text"
id="portalUrl"
formControlName="portalUrl"
/>
<mat-hint>{{
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="macAddress">{{
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
}}</mat-label>
<input
matInput
type="text"
id="macAddress"
formControlName="macAddress"
/>
</mat-form-field>
<!-- <mat-form-field class="full-width">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
}}</mat-label>
<input matInput type="text" id="username" formControlName="username" />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label for="password">{{
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field> -->
<button
mat-button
type="submit"
[disabled]="!form.valid"
(click)="addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
</button>
</form>
@@ -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;
}
}
@@ -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;
}
@@ -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: `
<div id="audio-player">
<div class="radio-logo">
<img [src]="icon" alt="radio icon" />
</div>
<audio preload="metadata" autoplay #audio>
<source [src]="url" />
</audio>
<div class="controls">
<button mat-icon-button (click)="switchChannel('previous')">
<mat-icon>skip_previous</mat-icon>
</button>
<button
class="icon-button-large"
mat-icon-button
(click)="play()"
*ngIf="playState === 'paused'"
>
<mat-icon>play_arrow</mat-icon>
</button>
<button
class="icon-button-large"
mat-icon-button
(click)="stop()"
*ngIf="playState === 'play'"
>
<mat-icon>pause</mat-icon>
</button>
<button mat-icon-button (click)="switchChannel('next')">
<mat-icon>skip_next</mat-icon>
</button>
</div>
<div class="volume-panel">
<div class="playing" *ngIf="playState === 'play'">
<span class="playing-bar playing-bar1"></span>
<span class="playing-bar playing-bar2"></span>
<span class="playing-bar playing-bar3"></span>
</div>
<div class="playing" *ngIf="playState === 'paused'">
<span class="playing-bar-stopped playing-bar1"></span>
<span class="playing-bar-stopped playing-bar2"></span>
<span class="playing-bar-stopped playing-bar3"></span>
</div>
<mat-slider min="0" max="1" step="0.1" color="accent">
<input matSliderThumb [(ngModel)]="audio.volume" />
</mat-slider>
<button mat-icon-button (click)="mute()">
<mat-icon *ngIf="audio.volume > 0 && !audio.muted"
>volume_up</mat-icon
>
<mat-icon *ngIf="audio.volume === 0 || audio.muted"
>volume_off</mat-icon
>
</button>
</div>
</div>
`,
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<HTMLAudioElement>;
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 }));
}
}
@@ -6,42 +6,34 @@
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
</div>
</ng-template>
<mat-list>
<mat-list-item>
<input
class="full-width search-bar"
type="search"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchTerm.name"
/>
</mat-list-item>
</mat-list>
<div class="search-panel">
<input
class="full-width search-bar"
type="search"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchTerm.name"
/>
</div>
<mat-divider></mat-divider>
<mat-nav-list>
<cdk-virtual-scroll-viewport itemSize="50" class="scroll-viewport">
<ng-container
*cdkVirtualFor="
let channel of _channelList
| filterBy: searchTerm.name:'name';
index as i;
trackBy: trackByFn
"
>
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
></app-channel-list-item>
</ng-container>
</cdk-virtual-scroll-viewport>
<app-channel-list-item
*ngFor="
let channel of _channelList
| filterBy: searchTerm.name : 'name';
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
></app-channel-list-item>
</mat-nav-list>
</mat-tab>
@@ -67,7 +59,11 @@
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<ng-container
*ngFor="let channel of groups.value; index as i"
*ngFor="
let channel of groups.value;
index as i;
trackBy: trackByFn
"
>
<app-channel-list-item
[name]="
@@ -105,7 +101,11 @@
>
<ng-container *ngIf="favorites.length > 0; else noFavorites">
<app-channel-list-item
*ngFor="let channel of favorites; index as i"
*ngFor="
let channel of favorites;
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
@@ -6,6 +6,10 @@
min-height: calc(100vh - 165px);
}
.search-panel {
padding: 10px;
}
.search-bar {
padding: 8px;
background: #ddd;
@@ -13,6 +17,11 @@
border-radius: 4px;
}
mat-nav-list {
overflow: scroll;
max-height: calc(100vh - 193px);
}
::ng-deep {
.mat-expansion-panel-body {
padding: 0 !important;
@@ -29,7 +29,7 @@
<mat-icon>chevron_left</mat-icon>
</button>
<span class="selected-date">
{{ dateToday | momentDate: 'YYYYMMDD':'MMMM Do, dddd' }}</span
{{ dateToday | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}</span
>
<button
class="next-day"
@@ -47,11 +47,12 @@
<mat-selection-list
id="program-list"
[multiple]="false"
hideSingleSelectionIndicator="true"
*ngIf="timeshiftUntil$ | async as timeshiftUntil"
>
<ng-container *ngIf="items?.length > 0; else epgNotAvailable">
<ng-container *ngFor="let item of items">
<mat-list-item
<mat-list-option
*ngIf="
item.start < timeshiftUntil || item.start >= timeNow;
else timeshiftItem
@@ -65,7 +66,7 @@
></app-epg-list-item>
</div>
<p matListItemLine>{{ item?.title[0]?.value }}</p>
</mat-list-item>
</mat-list-option>
<ng-template #timeshiftItem>
<mat-list-option
[value]="item"
@@ -17,17 +17,12 @@
flex-direction: column;
box-sizing: border-box;
display: flex;
flex: 1 1 120px;
max-height: 120px;
min-height: 120px;
.channel-info {
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 79px;
max-height: 79px;
min-height: 79px;
padding: 10px;
}
.channel-icon {
@@ -80,31 +75,11 @@ mat-selection-list {
}
#date-navigator {
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 40px;
max-height: 40px;
min-height: 40px;
justify-content: space-between;
align-items: center;
.selected-date {
font-size: 0.9em;
place-content: center;
align-items: center;
flex-direction: row;
box-sizing: border-box;
display: flex;
flex: 1 1 100%;
max-width: 100%;
}
.previous-day {
flex: 1 1 40px;
box-sizing: border-box;
}
.next-day {
flex: 1 1 40px;
box-sizing: border-box;
}
}
@@ -38,18 +38,14 @@ export class InfoOverlayComponent implements OnChanges {
* @param changes component input changes
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes.channel && !changes.channel.firstChange) {
if (changes.channel) {
clearTimeout(this.currentTimeout);
this.isVisible = true;
this.currentTimeout = setTimeout(() => {
this.isVisible = false;
}, 4000);
}
if (
changes.epgProgram &&
changes.epgProgram.currentValue &&
!changes.epgProgram.firstChange
) {
if (changes.epgProgram && changes.epgProgram.currentValue) {
const { stop, start } = changes.epgProgram.currentValue;
this.setProgramDuration(start, stop);
}
@@ -36,6 +36,7 @@ rect {
.parent {
display: flex;
background-color: black;
color: #fff;
}
#channels-column {
@@ -51,9 +52,10 @@ rect {
#epg-navigation {
display: flex;
background-color: black;
background-color: white;
margin-top: 30px;
border-bottom: 2px solid #fff;
align-items: center;
}
.today-date {
@@ -68,3 +70,11 @@ rect {
stroke-width: 2px;
stroke: white;
}
::ng-deep {
.dark-theme {
#epg-navigation {
background-color: black;
}
}
}
@@ -2,51 +2,92 @@
<!-- sidebar content -->
<mat-drawer #drawer mode="side" opened disableClose>
<app-sidebar
*ngIf="(channels$ | async) as channels; else noChannels"
[channels]="channels" />
<ng-template #noChannels>No channels <br /> <button mat-button color="secondary" (click)="navigateHome()">Go back</button></ng-template>
*ngIf="channels$ | async as channels; else noChannels"
[channels]="channels"
/>
<ng-template #noChannels
>No channels <br />
<button mat-button color="secondary" (click)="navigateHome()">
Go back
</button></ng-template
>
</mat-drawer>
<mat-drawer-content>
<ng-container *ngIf="activeChannel$ | async as activeChannel">
<!-- toolbar with drawer icon -->
<app-toolbar (toggleLeftDrawerClicked)="drawer.toggle()" (toggleRightDrawerClicked)="drawerRight.toggle()"
(multiEpgClicked)="openMultiEpgView()" [activeChannel]="activeChannel" />
<app-toolbar
(toggleLeftDrawerClicked)="drawer.toggle()"
(toggleRightDrawerClicked)="drawerRight.toggle()"
(multiEpgClicked)="openMultiEpgView()"
[activeChannel]="activeChannel"
/>
<!-- video.js player -->
<app-vjs-player
*ngIf="playerSettings.player === 'videojs'"
[options]="{
sources: [
{
src: activeChannel?.url + activeChannel?.epgParams,
type: 'application/x-mpegURL'
}
]
}"
[class.hide-captions]="!playerSettings.showCaptions"
></app-vjs-player>
<app-audio-player
*ngIf="activeChannel.radio === 'true'; else videoPlayer"
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo"
/>
<ng-template #videoPlayer>
<!-- video.js player -->
<app-vjs-player
*ngIf="playerSettings.player === 'videojs'"
[options]="{
sources: [
{
src:
activeChannel?.url +
activeChannel?.epgParams,
type: 'application/x-mpegURL'
}
]
}"
[class.hide-captions]="!playerSettings.showCaptions"
></app-vjs-player>
<!-- default html player component -->
<app-html-video-player
*ngIf="playerSettings.player === 'html5'"
[channel]="activeChannel"
[showCaptions]="playerSettings.showCaptions"
/>
<div
class="custom-player"
*ngIf="playerSettings.player === 'mpv'"
>
<img src="./assets/images/custom-player.png" />
<p>
The channel is played in a separate window in the MPV
player. (<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>Installation instructions</a
>)
</p>
</div>
<div
class="custom-player"
*ngIf="playerSettings.player === 'vlc'"
>
<img src="./assets/images/custom-player.png" />
<p>
The channel is played in a separate window in VLC
player. Please make sure that this player is installed
in your system and the correct path is defined in the
app settings.
</p>
</div>
<!-- channel overlay -->
<app-info-overlay
[channel]="activeChannel"
[epgProgram]="epgProgram$ | async"
></app-info-overlay>
</ng-template>
</ng-container>
<!-- default html player component -->
<app-html-video-player
*ngIf="playerSettings.player === 'html5'"
[channel]="activeChannel$ | async"
[showCaptions]="playerSettings.showCaptions"
>
</app-html-video-player>
<ng-container *ngIf="(activeChannel$ | async) && playerSettings.player === 'mpv'">
<div class="mpv-player">
<img src="./assets/images/custom-player.png" />
<p>The channel is played in a separate window in the MPV player. (<a [routerLink] style="cursor: pointer;" (click)="openUrl('https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F')">Installation instructions</a>)</p>
</div>
</ng-container>
<!-- channel overlay -->
<app-info-overlay
[channel]="activeChannel$ | async"
[epgProgram]="epgProgram$ | async"
></app-info-overlay>
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer position="end" #drawerRight mode="side" disableClose>
@@ -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%);
@@ -142,6 +142,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
: {}
);
this.store.dispatch(
PlaylistActions.setChannels({
channels: playlist.playlist.items,
})
);
return playlist.playlist.items;
})
);
+2
View File
@@ -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,
+13 -23
View File
@@ -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,
+16 -1
View File
@@ -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, {
+41
View File
@@ -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<string, string> }
);
} else if (type === STALKER_REQUEST) {
this.forwardStalkerRequest(
payload as {
url: string;
macAddress: string;
params: Record<string, string>;
}
);
}
}
@@ -145,13 +155,22 @@ export class PwaService extends DataService {
forwardXtreamRequest(payload: {
url: string;
params: Record<string, string>;
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<string, string>;
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 },
+25 -2
View File
@@ -16,8 +16,11 @@
<p>Add single or multiple URLs as EPG sources</p>
</div>
<div class="column">
<div *ngFor="let _ of epgUrl.controls; index as i">
<mat-form-field appearance="outline">
<div
*ngFor="let _ of epgUrl.controls; index as i"
style="display: flex"
>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{
'SETTINGS.EPG_URL_PLACEHOLDER' | translate
}}</mat-label>
@@ -110,6 +113,26 @@
</div>
</div>
<mat-divider></mat-divider>
<div class="row" *ngIf="settingsForm.value.player === 'vlc'">
<div class="column">
{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}
<p>{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}</p>
</div>
<div class="column">
<mat-form-field appearance="outline">
<mat-label for="vlcPlayerPath">{{
'SETTINGS.VLC_PLAYER_PATH' | translate
}}</mat-label>
<input
matInput
type="text"
id="vlcPlayerPath"
formControlName="vlcPlayerPath"
/>
</mat-form-field>
</div>
</div>
<mat-divider></mat-divider>
<div class="row">
<div class="column">
{{ 'SETTINGS.LANGUAGE' | translate }}
@@ -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;
+58 -53
View File
@@ -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);
}
});
+2
View File
@@ -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;
}
@@ -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: `
<h2 mat-dialog-title>Information</h2>
<mat-dialog-content class="mat-typography">
<app-player-view />
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
`,
imports: [
MatButtonModule,
MatDialogModule,
PlayerViewComponent,
TranslateModule,
],
})
export class ExternalPlayerInfoDialogComponent {}
+2
View File
@@ -18,4 +18,6 @@ export type PlaylistMeta = Pick<
| 'serverUrl'
| 'username'
| 'password'
| 'macAddress'
| 'portalUrl'
>;
+20
View File
@@ -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,
},
};
@@ -0,0 +1,48 @@
<app-navigation-bar
[breadcrumbs]="breadcrumbs"
[contentType]="selectedContentType"
(searchTextChanged)="setSearchPhrase($event)"
[clientSideSearch]="currentLayout === 'category_content' ? false : true"
[searchVisible]="
currentLayout === 'category' || currentLayout === 'category_content'
"
[contentTypeNavigationItems]="navigationContentTypes"
[contentType]="selectedContentType"
(contentTypeChanged)="getCategories($event)"
(breadcrumbClicked)="breadcrumbClicked($event)"
(favoritesClicked)="favoritesClicked()"
/>
<app-category-view
*ngIf="currentLayout === 'category'"
(categoryClicked)="categoryClicked($event)"
[items]="items"
/>
<app-category-content-view
*ngIf="currentLayout === 'category_content'"
[items]="items"
(itemClicked)="itemClicked($event)"
/>
<app-vod-details
*ngIf="currentLayout === 'vod-details'"
[item]="itemDetails"
(playClicked)="playVod()"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($event)"
/>
<app-category-content-view
*ngIf="currentLayout === 'favorites'"
[items]="favorites$ | async"
(itemClicked)="itemClicked($event)"
/>
<mat-paginator
*ngIf="currentLayout === 'category_content'"
[length]="length"
[pageSize]="pageSize"
(page)="handlePageChange($event)"
aria-label="Select page"
/>
@@ -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<Settings>;
favorites$: Observable<any>;
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<string, string | number>) {
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);
});
}
}
}
+7
View File
@@ -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';
}>()
);
+89 -6
View File
@@ -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,
+9 -3
View File
@@ -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
+4 -1
View File
@@ -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;
}
@@ -1,8 +1,9 @@
<ng-container *ngIf="items?.length > 0; else noItems">
<div class="grid">
<mat-card
appearance="outlined"
*ngFor="
let item of items | filterBy : searchPhrase() : 'name';
let item of items | filterBy: searchPhrase() : 'name';
trackBy: trackByFn
"
(click)="itemClicked.emit(item)"
@@ -27,7 +28,7 @@
</mat-card>
<div
class="no-content"
*ngIf="!(items | filterBy : searchPhrase() : 'name')?.length"
*ngIf="!(items | filterBy: searchPhrase() : 'name')?.length"
>
<mat-icon class="icon">search</mat-icon>
<div>Nothing found, try to change you search request</div>
@@ -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);
}
@@ -14,10 +14,11 @@ import { PortalStore } from '../portal.store';
<ng-container *ngIf="items?.length > 0; else noItems">
<div class="grid">
<mat-card
appearance="outlined"
class="category-item"
*ngFor="
let item of items
| filterBy : searchText() : 'category_name';
| filterBy: searchText() : 'category_name';
trackBy: trackByFn
"
(click)="categoryClicked.emit(item)"
@@ -29,7 +30,7 @@ import { PortalStore } from '../portal.store';
<div
class="no-content"
*ngIf="
!(items | filterBy : searchText() : 'category_name')
!(items | filterBy: searchText() : 'category_name')
?.length
"
>
@@ -0,0 +1,6 @@
import { ContentType } from './content-type.enum';
export interface ContentTypeNavigationItem {
contentType: ContentType;
label: string;
}
+4 -3
View File
@@ -1,5 +1,6 @@
export enum ContentType {
LIVE_STREAMS = 'LIVE_STREAMS',
VODS = 'VODS',
SERIES = 'SERIES',
VODS = 'vod',
SERIES = 'series',
ITV = 'itv',
FAVORITES = 'favorites',
}
+2 -1
View File
@@ -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) {
@@ -8,17 +8,17 @@
name="contentType"
aria-label="Content Type"
[value]="contentType"
(change)="contentTypeChanged.emit($event.value)"
(change)="changeContentType($event.value)"
>
<mat-button-toggle [value]="ContentTypeEnum.LIVE_STREAMS"
>Live Streams</mat-button-toggle
>
<mat-button-toggle [value]="ContentTypeEnum.VODS"
>VOD Streams</mat-button-toggle
>
<mat-button-toggle [value]="ContentTypeEnum.SERIES"
>Series</mat-button-toggle
<mat-button-toggle
*ngFor="
let item of contentTypeNavigationItems;
trackBy: trackByValue
"
[value]="item.contentType"
>
{{ item.label }}
</mat-button-toggle>
</mat-button-toggle-group>
</div>
@@ -28,11 +28,15 @@
</div>
<div id="sub-panel">
<div class="breadcrumbs">
<span
*ngFor="let item of breadcrumbs"
<div
*ngFor="let item of breadcrumbs; let i = index"
(click)="processBreadcrumbClick(item)"
>{{ item.title }}</span
>
<span>{{ item.title }}</span>
<mat-icon *ngIf="i !== breadcrumbs.length - 1"
>chevron_right</mat-icon
>
</div>
</div>
<div class="search" *ngIf="searchVisible">
<input
@@ -40,7 +44,7 @@
placeholder="Search by name"
type="search"
[ngModel]="searchPhrase()"
(ngModelChange)="setSearchText($event)"
(ngModelChange)="searchPhraseUpdate.next($event)"
/>
</div>
</div>
@@ -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;
}
@@ -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<ContentType>();
@Output() breadcrumbClicked = new EventEmitter<Breadcrumb>();
@Output() favoritesClicked = new EventEmitter<void>();
@Output() searchTextChanged = new EventEmitter<string>();
ContentTypeEnum = ContentType;
portalStore = inject(PortalStore);
searchPhrase = this.portalStore.searchPhrase;
searchPhraseUpdate = new Subject<string>();
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;
}
}
@@ -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;
}
+12
View File
@@ -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<PortalState> {
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<PortalState> {
searchPhrase,
})
);
readonly setContent = this.updater(
(state, content: any[]): PortalState => ({
...state,
content,
})
);
}
@@ -10,8 +10,8 @@
>
<div class="image">
<img
*ngIf="item.info.movie_image; else placeholderCover"
[src]="item.info.movie_image"
*ngIf="item.info?.movie_image; else placeholderCover"
[src]="item.info?.movie_image"
/>
<ng-template #placeholderCover>
<div class="placeholder-cover"></div>
@@ -23,27 +23,27 @@
{{ item.info.description }}
</label>
<label *ngIf="item.info.releasedate">
<div class="label">Release date:</div>
<div class="label">{{ 'XTREAM.RELEASE_DATE' | translate }}:</div>
{{ item.info.releasedate }}
</label>
<label *ngIf="item.info.genre">
<div class="label">Genre:</div>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ item.info.genre }}
</label>
<label *ngIf="item.info.country">
<div class="label">Country:</div>
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
{{ item.info.country }}
</label>
<div *ngIf="item.info.actors">
<div class="label">Actors:</div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ item.info.actors }}
</div>
<label *ngIf="item.info.director">
<div class="label">Director:</div>
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ item.info.director }}
</label>
<label *ngIf="item.info.duration">
<div class="label">Duration:</div>
<div class="label">{{ 'XTREAM.DURATION' | translate }}:</div>
{{ item.info.duration }}
</label>
<div class="action-buttons">
@@ -52,7 +52,7 @@
color="accent"
(click)="playClicked.emit(item)"
>
Play</button
{{ 'XTREAM.PLAY' | translate }}</button
>&nbsp;
<button
*ngIf="!isFavorite; else removeFromFavoritesButton"
@@ -60,7 +60,8 @@
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star_outline</mat-icon> Add to favorites
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
<ng-template #removeFromFavoritesButton>
@@ -69,7 +70,8 @@
color="accent"
(click)="toggleFavorite()"
>
<mat-icon>star</mat-icon>Remove from favorites
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
</ng-template>
</div>
@@ -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;
}
@@ -8,6 +8,7 @@
[searchVisible]="
currentLayout === 'category' || currentLayout === 'category_content'
"
[contentTypeNavigationItems]="navigationContentTypes"
/>
<div class="container">
@@ -49,7 +50,5 @@
[items]="favorites$ | async"
(itemClicked)="itemClicked($event)"
/>
<app-player-view *ngIf="currentLayout === 'player'" />
</ng-template>
</div>
@@ -1,8 +1,6 @@
.container {
height: calc(100vh - 140px);
overflow-y: auto;
display: flex;
justify-content: center;
}
mat-spinner {
@@ -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<any>;
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;
}
+48 -5
View File
@@ -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"
}
+23 -3
View File
@@ -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"
}
}
+46 -4
View File
@@ -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"
}
+46 -4
View File
@@ -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"
}
+23 -3
View File
@@ -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"
}
}
+46 -4
View File
@@ -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": "업데이트 가능"
}
+44 -6
View File
@@ -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": "Избранное"
}
}
+46 -4
View File
@@ -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": "可用更新"
}
+28 -2
View File
@@ -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"
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
@@ -26,7 +26,7 @@
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
href="https://fonts.googleapis.com/css2?family=Material+Icons"
rel="stylesheet"
/>
<script>
-2
View File
@@ -23,8 +23,6 @@ export interface ParsedPlaylistItem {
tvg: {
id: string;
name: string;
language: string;
country: string;
url: string;
logo: string;
rec: string;