mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
Merge branch 'electron'
This commit is contained in:
commit
f3ade02d4d
76 files changed
+2292
-466
No files matched your search
+3
-2
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
Generated
+139
-47
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -32,4 +32,6 @@ export interface Playlist {
|
||||
serverUrl?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
macAddress?: string;
|
||||
portalUrl?: string;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum StalkerPortalActions {
|
||||
GetCategories = 'get_categories',
|
||||
GetGenres = 'get_genres',
|
||||
CreateLink = 'create_link',
|
||||
GetOrderedList = 'get_ordered_list',
|
||||
Favorites = 'favorites',
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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 }));
|
||||
}
|
||||
}
|
||||
+36
-36
@@ -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;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+27
@@ -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 {}
|
||||
@@ -18,4 +18,6 @@ export type PlaylistMeta = Pick<
|
||||
| 'serverUrl'
|
||||
| 'username'
|
||||
| 'password'
|
||||
| 'macAddress'
|
||||
| 'portalUrl'
|
||||
>;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}>()
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum ContentType {
|
||||
LIVE_STREAMS = 'LIVE_STREAMS',
|
||||
VODS = 'VODS',
|
||||
SERIES = 'SERIES',
|
||||
VODS = 'vod',
|
||||
SERIES = 'series',
|
||||
ITV = 'itv',
|
||||
FAVORITES = 'favorites',
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
<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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "可用更新"
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Vendored
-2
@@ -23,8 +23,6 @@ export interface ParsedPlaylistItem {
|
||||
tvg: {
|
||||
id: string;
|
||||
name: string;
|
||||
language: string;
|
||||
country: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
rec: string;
|
||||
|
||||
Reference in new issue
Block a user