diff --git a/.gitignore b/.gitignore index 112be1b34..8632844de 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ preload.js shared/*.js playwright-report .nx +server.js # dependencies /node_modules diff --git a/electron/api.ts b/electron/api.ts index c523b348c..f12b1373e 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -32,6 +32,7 @@ import { PLAYLIST_UPDATE_RESPONSE, SET_MPV_PLAYER_PATH, SET_VLC_PLAYER_PATH, + SETTINGS_UPDATE, STALKER_REQUEST, STALKER_RESPONSE, XTREAM_REQUEST, @@ -40,6 +41,7 @@ import { import { Playlist } from '../shared/playlist.interface'; import { createPlaylistObject } from '../shared/playlist.utils'; import { ParsedPlaylist } from '../src/typings.d'; +import { Server } from './server'; const fs = require('fs'); const https = require('https'); @@ -88,7 +90,12 @@ export class Api { mpv; + settings; + + server; + constructor(store) { + this.server = new Server(this); this.store = store; this.mpv = this.createMpvInstance(); this.mpv @@ -286,7 +293,11 @@ export class Api { .on(SET_VLC_PLAYER_PATH, (_event, vlcPlayerPath) => { console.log('... setting vlc player path', vlcPlayerPath); store.set(VLC_PLAYER_PATH, vlcPlayerPath); - }); + }) + .on(SETTINGS_UPDATE, (_event, arg) => { + this.settings = arg; + this.server.updateSettings(); + }) ; // listeners for EPG events ipcMain @@ -625,4 +636,5 @@ export class Api { } }); } + } diff --git a/electron/main.ts b/electron/main.ts index 380c4ccd5..4900d05ba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -120,7 +120,7 @@ try { const menu = new AppMenu(win); Menu.setApplicationMenu(menu.getMenu()); api.setMainWindow(win); - + // create hidden window for epg worker createEpgWorkerWindow(); }); diff --git a/electron/remote-control/index.sqrl b/electron/remote-control/index.sqrl new file mode 100644 index 000000000..233f4ce3e --- /dev/null +++ b/electron/remote-control/index.sqrl @@ -0,0 +1,85 @@ + + + {{it.title}} + + + + + +
+

{{it.headerText}}

+
+ +
+ + +
+ + + + diff --git a/electron/server.ts b/electron/server.ts new file mode 100644 index 000000000..bc603e700 --- /dev/null +++ b/electron/server.ts @@ -0,0 +1,86 @@ +import { IncomingMessage, ServerResponse } from "http"; +import { REMOTE_CONTROL_CHANGE_CHANNEL } from "../shared/ipc-commands"; + +const http = require('http'); +const Sqrl = require('squirrelly'); +const fs = require('fs'); +const path = require('path'); + +export class Server { + /** Server instance */ + server: any; + /** Api instance */ + api: any; + + /** + * Creates a new server instance + * @param port port number + * @param api application instance + */ + constructor(_api: any) { + this.api = _api; + this.server = http.createServer(this.requestListener.bind(this)); + } + + updateSettings() { + if (this.api.settings.remoteControl && !this.server.listening) { + this.server.listen(this.api.settings.remoteControlPort); + } else if (this.server.listening && !this.api.settings.remoteControl) { + this.server.close(); + } + } + + async getTranslation() { + const language = this.api.settings && this.api.settings.language ? this.api.settings.language : 'en'; + const languageFileContent = await fs.promises.readFile(path.join(__dirname, `../src/assets/i18n/${language}.json`), 'utf-8'); + return JSON.parse(languageFileContent); + } + + getTranslationValue(key: string, translation: any, defaultValue: string) { + if (translation && translation.REMOTE_CONTROL) { + const translationValue = translation.REMOTE_CONTROL[key]; + if (translationValue) { + return translationValue; + } + } + return defaultValue; + } + + async requestListener(request: IncomingMessage, response: ServerResponse) { + const currentPage = request.url?.split('?')[0]?.split('/').splice(-1)[0]; + const translation = await this.getTranslation(); + switch (currentPage) { + case '': { + const indexFile = await fs.promises.readFile(path.join(__dirname, './remote-control/', 'index.sqrl'), 'utf-8'); + const renderedHtml = Sqrl.render(indexFile, { + headerText: this.getTranslationValue('HEADER', translation, 'Remote Control'), + upChannelText: this.getTranslationValue('UP_CHANNEL', translation, 'Up Channel'), + downChannelText: this.getTranslationValue('DOWN_CHANNEL', translation, 'Down Channel'), + upChannelUrl: '/upChannel', + downChannelUrl: '/downChannel', + title: this.getTranslationValue('TITLE', translation, 'IPTVNator'), + footer: this.getTranslationValue('FOOTER', translation, 'IPTVNator') + }); + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end(renderedHtml); + break; + } + case 'upChannel': { + this.api.mainWindow.webContents.send(REMOTE_CONTROL_CHANGE_CHANNEL, { type: 'up' }); + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end('Up Channel'); + break; + } + case 'downChannel': { + this.api.mainWindow.webContents.send(REMOTE_CONTROL_CHANGE_CHANNEL, { type: 'down' }); + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end('Down Channel'); + break; + } + default: + response.writeHead(404, { 'Content-Type': 'text/html' }); + response.end('404'); + break; + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index dbd33261b..288dcf614 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "node-mpv": "github:4gray/Node-MPV", "rxjs": "7.8.1", "semver": "7.5.2", + "squirrelly": "9.1.0", "uuid": "9.0.0", "video.js": "7.20.3", "videojs-contrib-quality-levels": "2.2.0", @@ -30680,6 +30681,18 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, + "node_modules/squirrelly": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/squirrelly/-/squirrelly-9.1.0.tgz", + "integrity": "sha512-kvjFqb7qzC4gX4lkqSaU8QPvUHhDLMiDpxpz7a66vjTH0JtjLJqAXbPrc7ST61EefuuuW05sne2rjGskunrF2A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/squirrellyjs/squirrelly?sponsor=1" + } + }, "node_modules/ssri": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", diff --git a/package.json b/package.json index 9cd7ff07c..02d9d53b0 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "node-mpv": "github:4gray/Node-MPV", "rxjs": "7.8.1", "semver": "7.5.2", + "squirrelly": "9.1.0", "uuid": "9.0.0", "video.js": "7.20.3", "videojs-contrib-quality-levels": "2.2.0", diff --git a/shared/ipc-commands.ts b/shared/ipc-commands.ts index a8ab6408c..e7057b4ab 100644 --- a/shared/ipc-commands.ts +++ b/shared/ipc-commands.ts @@ -54,3 +54,9 @@ export const XTREAM_RESPONSE = 'XTREAM_RESPONSE'; // Stalker export const STALKER_REQUEST = 'STALKER_REQUEST'; export const STALKER_RESPONSE = 'STALKER_RESPONSE'; + +// Settings +export const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; + +// Remote Control +export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL'; \ No newline at end of file diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index d2453c1b4..dae2626dd 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -219,7 +219,7 @@ describe('AppComponent', () => { expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings); expect(settingsService.changeTheme).toHaveBeenCalledWith(theme); - expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1); + expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(2); expect(translateService.use).toHaveBeenCalledWith(language); }); @@ -245,7 +245,7 @@ describe('AppComponent', () => { expect(spyOnSettingsGet).toHaveBeenCalledWith(STORE_KEY.Settings); expect(settingsService.changeTheme).toHaveBeenCalledWith(theme); - expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(0); + expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1); expect(translateService.use).toHaveBeenCalledWith(defaultLanguage); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 622d6b4d2..08244bd00 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -13,6 +13,7 @@ import { EPG_FETCH_DONE, ERROR, OPEN_FILE, + SETTINGS_UPDATE, SHOW_WHATS_NEW, VIEW_ADD_PLAYLIST, VIEW_SETTINGS, @@ -148,6 +149,7 @@ export class AppComponent { .getValueFromLocalStorage(STORE_KEY.Settings) .subscribe((settings: Settings) => { if (settings && Object.keys(settings).length > 0) { + this.electronService.sendIpcEvent(SETTINGS_UPDATE, settings); this.translate.use(settings.language ?? this.DEFAULT_LANG); if ( settings.epgUrl?.length > 0 && diff --git a/src/app/portals/live-stream-layout/live-stream-layout.component.html b/src/app/portals/live-stream-layout/live-stream-layout.component.html index 310b84152..3090dbde8 100644 --- a/src/app/portals/live-stream-layout/live-stream-layout.component.html +++ b/src/app/portals/live-stream-layout/live-stream-layout.component.html @@ -30,6 +30,7 @@ trackBy: trackBy; templateCacheSize: 0 " + [selected]="item.stream_id === activeLiveStream?.stream_id" [value]="item" (click)="itemClicked.emit(item)" > diff --git a/src/app/portals/live-stream-layout/live-stream-layout.component.ts b/src/app/portals/live-stream-layout/live-stream-layout.component.ts index d92d82ca4..74f7d52f3 100644 --- a/src/app/portals/live-stream-layout/live-stream-layout.component.ts +++ b/src/app/portals/live-stream-layout/live-stream-layout.component.ts @@ -46,6 +46,7 @@ export class LiveStreamLayoutComponent { @Input({ required: true }) player: VideoPlayer = VideoPlayer.VideoJs; @Input() epgItems: EpgItem[]; @Input() streamUrl: string; + @Input() activeLiveStream: XtreamItem; @Output() itemClicked = new EventEmitter(); diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index f724c58f9..73dfe885d 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -200,6 +200,36 @@ +
+
+ {{ 'SETTINGS.REMOTE_CONTROL' | translate }} +

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+
+
+ +
+
+ +
+
+ {{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }} +

{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}

+
+
+ + + +
+
+
{{ 'SETTINGS.VERSION' | translate }} diff --git a/src/app/settings/settings.component.spec.ts b/src/app/settings/settings.component.spec.ts index 760112149..352783853 100644 --- a/src/app/settings/settings.component.spec.ts +++ b/src/app/settings/settings.component.spec.ts @@ -62,6 +62,8 @@ const DEFAULT_SETTINGS = { theme: Theme.LightTheme, mpvPlayerPath: '', vlcPlayerPath: '', + remoteControl: false, + remoteControlPort: 3000 }; describe('SettingsComponent', () => { diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index e48d72de1..7f99f6f7d 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -25,6 +25,7 @@ import { EPG_FORCE_FETCH, SET_MPV_PLAYER_PATH, SET_VLC_PLAYER_PATH, + SETTINGS_UPDATE, } from '../../../shared/ipc-commands'; import { Playlist } from '../../../shared/playlist.interface'; import { DataService } from '../services/data.service'; @@ -111,6 +112,8 @@ export class SettingsComponent implements OnInit { theme: Theme.LightTheme, mpvPlayerPath: '', vlcPlayerPath: '', + remoteControl: false, + remoteControlPort: 3000 }); /** Form array with epg sources */ @@ -161,6 +164,8 @@ export class SettingsComponent implements OnInit { theme: settings.theme ?? Theme.LightTheme, mpvPlayerPath: settings.mpvPlayerPath ?? '', vlcPlayerPath: settings.vlcPlayerPath ?? '', + remoteControl: settings.remoteControl ?? false, + remoteControlPort: settings.remoteControlPort ?? 3000 }); } catch (error) { throw new Error(error); @@ -250,6 +255,8 @@ export class SettingsComponent implements OnInit { this.applyChangedSettings(); }); + this.electronService.sendIpcEvent(SETTINGS_UPDATE, this.settingsForm.value); + this.electronService.sendIpcEvent( SET_MPV_PLAYER_PATH, this.settingsForm.value.mpvPlayerPath diff --git a/src/app/settings/settings.interface.ts b/src/app/settings/settings.interface.ts index f31a3f981..e521ae7c6 100644 --- a/src/app/settings/settings.interface.ts +++ b/src/app/settings/settings.interface.ts @@ -23,4 +23,6 @@ export interface Settings { theme: Theme; mpvPlayerPath: string; vlcPlayerPath: string; + remoteControl: boolean; + remoteControlPort: number; } diff --git a/src/app/xtream/xtream-main-container.component.html b/src/app/xtream/xtream-main-container.component.html index e32d28725..0c581c843 100644 --- a/src/app/xtream/xtream-main-container.component.html +++ b/src/app/xtream/xtream-main-container.component.html @@ -36,6 +36,7 @@ [player]="player" [streamUrl]="streamUrl" [epgItems]="epgItems" + [activeLiveStream]="activeLiveStream" (itemClicked)="itemClicked($event)" /> } @else { diff --git a/src/app/xtream/xtream-main-container.component.ts b/src/app/xtream/xtream-main-container.component.ts index 5d4869e77..34d48b55f 100644 --- a/src/app/xtream/xtream-main-container.component.ts +++ b/src/app/xtream/xtream-main-container.component.ts @@ -21,6 +21,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { IpcCommand } from '../../../shared/ipc-command.class'; import { + REMOTE_CONTROL_CHANGE_CHANNEL, ERROR, OPEN_MPV_PLAYER, OPEN_VLC_PLAYER, @@ -183,6 +184,7 @@ export class XtreamMainContainerComponent implements OnInit { streamUrl: string; epgItems = []; hideExternalInfoDialog = this.portalStore.hideExternalInfoDialog; + activeLiveStream: XtreamLiveStream; commandsList = [ new IpcCommand(XTREAM_RESPONSE, (response: XtreamResponse) => @@ -191,6 +193,9 @@ export class XtreamMainContainerComponent implements OnInit { new IpcCommand(ERROR, (response: { message: string; status: number }) => this.showErrorAsNotification(response) ), + new IpcCommand(REMOTE_CONTROL_CHANGE_CHANNEL, (response: { type: 'up' | 'down' }) => { + this.remoteControlChangeChannel(response.type); + }) ]; constructor() { @@ -345,6 +350,7 @@ export class XtreamMainContainerComponent implements OnInit { playLiveStream(item: XtreamLiveStream) { const { serverUrl, username, password } = this.currentPlaylist(); const streamUrl = `${serverUrl}/${item.stream_type}/${username}/${password}/${item.stream_id}.m3u8`; + this.activeLiveStream = item; this.openPlayer(streamUrl, item.name); } @@ -530,4 +536,22 @@ export class XtreamMainContainerComponent implements OnInit { this.currentLayout = 'favorites'; this.setInitialBreadcrumb(); } + + remoteControlChangeChannel(type: 'up' | 'down') { + if (this.currentLayout === 'category_content' && this.activeLiveStream) { + let nextItem; + const index = this.activeLiveStream.num - 1; + if (type === 'up') { + if (index - 1 >= 0) { + nextItem = this.items[index - 1]; + } + } else if (type === 'down') { + const index = this.activeLiveStream.num - 1; + if (index + 1 < this.items.length) { + nextItem = this.items[index + 1]; + } + } + this.itemClicked(nextItem); + } + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b87b9840a..3a03add75 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -138,7 +138,11 @@ "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" + "VLC_PLAYER_PATH": "VLC player path", + "REMOTE_CONTROL": "Remote control", + "REMOTE_CONTROL_DESCRIPTION": "Enable remote control of the application.", + "REMOTE_CONTROL_PORT": "Remote control port", + "REMOTE_CONTROL_PORT_DESCRIPTION": "Port used for remote control of the application. Port 3000 is used by default." }, "THEMES": { "DARK_THEME": "Dark theme", @@ -283,5 +287,12 @@ "STREAM_URL_COPIED": "Stream URL copied to clipboard", "COPY_STREAM_URL": "Copy Stream URL" }, - "INFORMATION": "Information" + "INFORMATION": "Information", + "REMOTE_CONTROL": { + "TITLE": "Remote control", + "UP_CHANNEL": "Up channel", + "DOWN_CHANNEL": "Down channel", + "HEADER": "IPTVNator Remote Control", + "FOOTER": "IPTVNator Remote Control" + } } diff --git a/src/assets/i18n/tr.json b/src/assets/i18n/tr.json index 51d0e4e36..54cca15d4 100644 --- a/src/assets/i18n/tr.json +++ b/src/assets/i18n/tr.json @@ -138,7 +138,11 @@ "MPV_PLAYER_PATH": "mpv oynatıcı yolu", "VLC_PLAYER_PATH_LABEL": "VLC yolu", "VLC_PLAYER_PATH_DESCRIPTION": "Sisteminizdeki VLC oynatıcı yolunu ayarlayın. Sistemimiz için varsayılan yolu kullanmak yerine, sağlanan VLC ikili dosyası yolunu kullanacaktır.", - "VLC_PLAYER_PATH": "VLC oynatıcı yolu" + "VLC_PLAYER_PATH": "VLC oynatıcı yolu", + "REMOTE_CONTROL": "Uzaktan kontrol", + "REMOTE_CONTROL_DESCRIPTION": "Uygulamanın uzaktan kontrolünü etkinleştirin.", + "REMOTE_CONTROL_PORT": "Uzaktan kontrol portu", + "REMOTE_CONTROL_PORT_DESCRIPTION": "Uzaktan kontrol için bir port belirleyin. Varsayılan olarak 3080 kullanılır." }, "THEMES": { "DARK_THEME": "Koyu Tema", @@ -283,5 +287,12 @@ "STREAM_URL_COPIED": "Yayın URL'si panoya kopyalandı", "COPY_STREAM_URL": "Yayın URL'sini Kopyala" }, - "INFORMATION": "Bilgi" + "INFORMATION": "Bilgi", + "REMOTE_CONTROL": { + "TITLE": "Uzaktan Kontrol", + "UP_CHANNEL": "Yukarı Kanal", + "DOWN_CHANNEL": "Aşağı Kanal", + "HEADER": "IPTVNator Uzaktan Kontrol", + "FOOTER": "IPTVNator Uzaktan Kontrol" + } }