From 9f1b12307838acaf960a9a5f0ebd4eaf230657aa Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 15 Nov 2024 23:18:54 +0100 Subject: [PATCH] refactor: tauri support for mpv/vlc --- src-tauri/src/lib.rs | 84 ++++++++ src/app/app.module.ts | 12 +- src/app/home/home.component.scss | 2 +- .../channel-list-container.component.html | 2 +- .../channel-list-container.component.scss | 10 +- src/app/services/data.service.ts | 3 +- src/app/services/electron.service.ts | 4 + src/app/services/pwa.service.ts | 4 + src/app/services/tauri.service.ts | 197 ++++++++++++++++++ src/app/settings/settings.component.html | 34 +-- src/app/settings/settings.component.ts | 17 +- .../components/header/header.component.ts | 12 +- .../stalker-main-container.component.ts | 2 + src/app/state/effects.ts | 2 + .../vod-details/vod-details.component.ts | 14 +- .../xtream-main-container.component.html | 5 +- .../xtream/xtream-main-container.component.ts | 27 ++- 17 files changed, 389 insertions(+), 42 deletions(-) create mode 100644 src/app/services/tauri.service.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 212717930..a8fcde000 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,92 @@ +use log::info; +use std::path::Path; +use std::process::Command; + +#[tauri::command] +async fn open_in_mpv(url: String, path: String) -> Result<(), String> { + info!("Custom MVP path: {}", path); + let mpv_paths = if cfg!(target_os = "windows") { + vec![ + r"C:\Program Files\mpv\mpv.exe", + r"C:\Program Files (x86)\mpv\mpv.exe", + ] + } else if cfg!(target_os = "linux") { + vec!["/usr/bin/mpv", "/usr/local/bin/mpv", "/snap/bin/mpv"] + } else { + vec![ + "/Applications/mpv.app/Contents/MacOS/mpv", + "/opt/homebrew/bin/mpv", + "/usr/local/bin/mpv", + ] + }; + + let mpv_path = if !path.is_empty() && Path::new(&path).exists() { + path + } else { + mpv_paths + .iter() + .find(|&path| Path::new(path).exists()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "mpv".to_string()) + }; + + info!("Using the following MPV player path: {}", mpv_path); + + Command::new(mpv_path) + .arg(&url) + .spawn() + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +async fn open_in_vlc(url: String, path: String) -> Result<(), String> { + info!("Custom MVP path: {}", path); + let vlc_paths = if cfg!(target_os = "windows") { + vec![ + r"C:\Program Files\VideoLAN\VLC\vlc.exe", + r"C:\Program Files (x86)\VideoLAN\VLC\vlc.exe", + ] + } else if cfg!(target_os = "linux") { + vec!["/usr/bin/vlc", "/usr/local/bin/vlc", "/snap/bin/vlc"] + } else { + vec![ + "/Applications/VLC.app/Contents/MacOS/VLC", + "/opt/homebrew/bin/vlc", + "/usr/local/bin/vlc", + ] + }; + + let vlc_path = if !path.is_empty() && Path::new(&path).exists() { + path + } else { + vlc_paths + .iter() + .find(|&path| Path::new(path).exists()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "vlc".to_string()) + }; + + info!("Using VLC player path: {}", vlc_path); + + Command::new(vlc_path) + .arg(&url) + .spawn() + .map_err(|e| e.to_string())?; + + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_window_state::Builder::new().build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_http::init()) + .invoke_handler(tauri::generate_handler![open_in_mpv, open_in_vlc]) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 418efe9b4..54d6141b0 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -24,6 +24,7 @@ import { dbConfig } from './indexed-db.config'; import { DataService } from './services/data.service'; import { ElectronService } from './services/electron.service'; import { PwaService } from './services/pwa.service'; +import { TauriService } from './services/tauri.service'; import { SharedModule } from './shared/shared.module'; import { PlaylistEffects } from './state/effects'; import { playlistReducer } from './state/reducers'; @@ -40,15 +41,20 @@ function isElectron() { return !!(window && window.process && (window.process as any).type); } +function isTauri() { + return '__TAURI_INTERNALS__' in window || '__TAURI__' in window; +} + /** * Conditionally imports the necessary service based on the current environment - * @param dbService indexed db service - * @returns */ -export function DataFactory(dbService: NgxIndexedDBService, http: HttpClient) { +export function DataFactory(http: HttpClient) { if (isElectron()) { return new ElectronService(); } + if (isTauri()) { + return new TauriService(); + } return new PwaService(http); } diff --git a/src/app/home/home.component.scss b/src/app/home/home.component.scss index 82beb5aa3..39151a654 100644 --- a/src/app/home/home.component.scss +++ b/src/app/home/home.component.scss @@ -13,7 +13,7 @@ } .recent-playlists { - height: calc(100vh - 106px); + height: calc(100vh - 100px); overflow: auto; } diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.html b/src/app/player/components/channel-list-container/channel-list-container.component.html index 20193adf4..746a1ff2b 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.html +++ b/src/app/player/components/channel-list-container/channel-list-container.component.html @@ -19,7 +19,7 @@ /> - + void ): void; + abstract getAppEnvironment(): string; } diff --git a/src/app/services/electron.service.ts b/src/app/services/electron.service.ts index c0cc62afe..8e73e9918 100644 --- a/src/app/services/electron.service.ts +++ b/src/app/services/electron.service.ts @@ -45,4 +45,8 @@ export class ElectronService extends DataService { listenOn(command: string, callback: (...args: any[]) => void): void { this.ipcRenderer.on(command, callback); } + + getAppEnvironment(): string { + return 'electron'; + } } diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index 209b3759d..7e5f6a1b3 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -234,4 +234,8 @@ export class PwaService extends DataService { listenOn(_command: string, callback: (...args: any[]) => void): void { window.addEventListener('message', callback); } + + getAppEnvironment(): string { + return 'pwa'; + } } diff --git a/src/app/services/tauri.service.ts b/src/app/services/tauri.service.ts new file mode 100644 index 000000000..8727e1734 --- /dev/null +++ b/src/app/services/tauri.service.ts @@ -0,0 +1,197 @@ +import { Injectable } from '@angular/core'; +import { invoke } from '@tauri-apps/api/core'; +import { fetch } from '@tauri-apps/plugin-http'; +import { parse } from 'iptv-playlist-parser'; +import { + ERROR, + PLAYLIST_PARSE_BY_URL, + PLAYLIST_PARSE_RESPONSE, + XTREAM_RESPONSE, +} from '../../../shared/ipc-commands'; +import { Playlist } from '../../../shared/playlist.interface'; +import { createPlaylistObject } from '../../../shared/playlist.utils'; +import { AppConfig } from '../../environments/environment'; +import { DataService } from './data.service'; + +@Injectable({ + providedIn: 'root', +}) +export class TauriService extends DataService { + constructor() { + super(); + console.log('Tauri service initialized...'); + } + + getAppVersion(): string { + return AppConfig.version; + } + + async sendIpcEvent(type: string, payload?: unknown) { + if (type === PLAYLIST_PARSE_BY_URL) { + this.fetchFromUrl(payload); + } else if (type === 'PLAYLIST_UPDATE') { + console.log('PLAYLIST_UPDATE'); + } else if (type === 'XTREAM_REQUEST') { + this.forwardXtreamRequest( + payload as { url: string; params: Record } + ); + } else if (type === 'STALKER_REQUEST') { + console.log('STALKER_REQUEST'); + this.fetchStalkerData( + payload as { + url: string; + macAddress: string; + params: Record; + } + ); + } else if (type === 'OPEN_MPV_PLAYER') { + return invoke('open_in_mpv', { + url: (payload as any).url, + path: (payload as any).mpvPlayerPath || '', + }).catch((error) => { + window.postMessage({ + type: ERROR, + message: `Error launching MPV: ${error}`, + }); + throw error; + }); + } else if (type === 'OPEN_VLC_PLAYER') { + return invoke('open_in_vlc', { + url: (payload as any).url, + path: (payload as any).vlcPlayerPath || '', + }).catch((error) => { + window.postMessage({ + type: ERROR, + message: `Error launching VLC: ${error}`, + }); + throw error; + }); + } else { + console.log('Unknown type', type); + } + } + + async fetchStalkerData(payload: { + url: string; + macAddress: string; + params: Record; + }) { + try { + console.log(payload); + const url = new URL(payload.url); + + Object.entries(payload.params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + Cookie: `mac=${payload.macAddress}`, + }, + }); + + if (!response.ok) { + throw new Error( + `Error: ${response.statusText} (Status: ${response.status})` + ); + } + + const result = await response.json(); + console.log(result); + window.postMessage({ + type: 'STALKER_RESPONSE', + payload: result, + action: payload.params.action, + }); + } catch (err) { + console.log(err); + window.postMessage({ + type: ERROR, + message: err.message ?? 'Error: not found', + status: err.status ?? 404, + }); + } + } + + async fetchFromUrl(payload: Partial) { + try { + const response = await fetch(payload.url); + const responseBody = await response.text(); + const parsedPlaylist = parse(responseBody); + + const playlist = createPlaylistObject( + 'tests', + parsedPlaylist, + payload.url, + 'URL' + ); + + window.postMessage({ + type: PLAYLIST_PARSE_RESPONSE, + payload: { ...playlist, isTemporary: payload.isTemporary }, + }); + } catch (error) { + window.postMessage({ + type: ERROR, + message: this.getErrorMessageByStatusCode(error.status), + status: error.status, + }); + } + } + + getErrorMessageByStatusCode(status: number) { + let message = 'Something went wrong'; + switch (status) { + case 0: + message = 'The backend is not reachable'; + break; + case 413: + message = + 'This file is too big. Use standalone or self-hosted version of the app.'; + break; + default: + break; + } + return message; + } + + async forwardXtreamRequest(payload: { + url: string; + params: Record; + }) { + const url = new URL(`${payload.url}/player_api.php`); + Object.entries(payload.params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + const response = await fetch(url.toString()); + + const responseBody = await response.json(); + if (!responseBody) { + window.postMessage({ + type: ERROR, + status: response.status, + message: responseBody.message ?? 'Unknown error', + }); + } else { + window.postMessage({ + type: XTREAM_RESPONSE, + payload: responseBody, + action: payload.params.action, + }); + } + } + + removeAllListeners(type: string): void { + throw new Error('Method not implemented.'); + } + + listenOn(command: string, callback: (...args: any[]) => void): void { + window.addEventListener('message', callback); + } + + getAppEnvironment(): string { + return 'tauri'; + } +} diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 73dfe885d..d66241621 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -199,24 +199,30 @@ > - -
-
- {{ 'SETTINGS.REMOTE_CONTROL' | translate }} -

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

+ @if (isPwa || isElectron) { + +
+
+ {{ 'SETTINGS.REMOTE_CONTROL' | translate }} +

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

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

{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}

+

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

@@ -294,7 +300,7 @@
-
+
{{ 'SETTINGS.EPG_NOTE' | translate }}  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 7f99f6f7d..8e31459dd 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -67,6 +67,8 @@ export class SettingsComponent implements OnInit { /** Flag that indicates whether the app runs in electron environment */ isElectron = this.electronService.isElectron; + isPwa = this.electronService.getAppEnvironment() === 'pwa'; + electronPlayers = [ { id: VideoPlayer.MPV, @@ -88,7 +90,8 @@ export class SettingsComponent implements OnInit { id: VideoPlayer.VideoJs, label: 'VideoJs Player', }, - ...(this.isElectron ? this.electronPlayers : []), + ...this.electronPlayers, + /* ...(this.isElectron ? this.electronPlayers : []), */ ]; /** Current version of the app */ @@ -113,7 +116,7 @@ export class SettingsComponent implements OnInit { mpvPlayerPath: '', vlcPlayerPath: '', remoteControl: false, - remoteControlPort: 3000 + remoteControlPort: 3000, }); /** Form array with epg sources */ @@ -125,7 +128,7 @@ export class SettingsComponent implements OnInit { */ constructor( private dialogService: DialogService, - private electronService: DataService, + public electronService: DataService, private epgService: EpgService, private formBuilder: FormBuilder, private playlistsService: PlaylistsService, @@ -165,7 +168,8 @@ export class SettingsComponent implements OnInit { mpvPlayerPath: settings.mpvPlayerPath ?? '', vlcPlayerPath: settings.vlcPlayerPath ?? '', remoteControl: settings.remoteControl ?? false, - remoteControlPort: settings.remoteControlPort ?? 3000 + remoteControlPort: + settings.remoteControlPort ?? 3000, }); } catch (error) { throw new Error(error); @@ -255,7 +259,10 @@ export class SettingsComponent implements OnInit { this.applyChangedSettings(); }); - this.electronService.sendIpcEvent(SETTINGS_UPDATE, this.settingsForm.value); + this.electronService.sendIpcEvent( + SETTINGS_UPDATE, + this.settingsForm.value + ); this.electronService.sendIpcEvent( SET_MPV_PLAYER_PATH, diff --git a/src/app/shared/components/header/header.component.ts b/src/app/shared/components/header/header.component.ts index 67a8ba871..22cde7efc 100644 --- a/src/app/shared/components/header/header.component.ts +++ b/src/app/shared/components/header/header.component.ts @@ -12,6 +12,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; +import { open } from '@tauri-apps/plugin-shell'; import { NgxWhatsNewModule } from 'ngx-whats-new'; import { HomeComponent } from '../../../home/home.component'; import { DataService } from '../../../services/data.service'; @@ -56,6 +57,9 @@ export class HeaderComponent implements OnInit { /** Environment flag */ isElectron = this.dataService.isElectron; + /** Environment flag for Tauri */ + isTauri = this.dataService.getAppEnvironment() === 'tauri'; + /** Visibility flag of the "what is new" modal dialog */ isDialogVisible$ = this.whatsNewService.dialogState$; @@ -121,8 +125,12 @@ export class HeaderComponent implements OnInit { * Opens the provided URL string in new browser window * @param url url to open */ - openUrl(url: string): void { - window.open(url, '_blank'); + async openUrl(url: string): Promise { + if (this.isTauri) { + await open(url); + } else { + window.open(url, '_blank'); + } } /** diff --git a/src/app/stalker/stalker-main-container.component.ts b/src/app/stalker/stalker-main-container.component.ts index 7a30706bf..ca1f60634 100644 --- a/src/app/stalker/stalker-main-container.component.ts +++ b/src/app/stalker/stalker-main-container.component.ts @@ -376,12 +376,14 @@ export class StalkerMainContainerComponent implements OnInit { this.dialog.open(ExternalPlayerInfoDialogComponent); this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl, + mpvPlayerPath: this.settings()?.mpvPlayerPath, }); } else if (player === VideoPlayer.VLC) { if (!this.hideExternalInfoDialog()) this.dialog.open(ExternalPlayerInfoDialogComponent); this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: streamUrl, + vlcPlayerPath: this.settings()?.vlcPlayerPath, }); } else { this.dialog.open( diff --git a/src/app/state/effects.ts b/src/app/state/effects.ts index 730855f67..40cfee40d 100644 --- a/src/app/state/effects.ts +++ b/src/app/state/effects.ts @@ -85,6 +85,7 @@ export class PlaylistEffects { url: activeChannel.url + (activeChannel.epgParams ?? ''), + mpvPlayerPath: settings?.mpvPlayerPath, }); else if ( settings && @@ -95,6 +96,7 @@ export class PlaylistEffects { url: activeChannel.url + (activeChannel.epgParams ?? ''), + vlcPlayerPath: settings?.vlcPlayerPath, }); } ); diff --git a/src/app/xtream/vod-details/vod-details.component.ts b/src/app/xtream/vod-details/vod-details.component.ts index fa5ce5b4a..1630f1633 100644 --- a/src/app/xtream/vod-details/vod-details.component.ts +++ b/src/app/xtream/vod-details/vod-details.component.ts @@ -8,7 +8,8 @@ import { inject, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; +import { MatIcon, MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { XtreamVodDetails } from '../../../../shared/xtream-vod-details.interface'; @@ -20,7 +21,15 @@ import { SafePipe } from './safe.pipe'; templateUrl: './vod-details.component.html', styleUrls: ['../detail-view.scss'], standalone: true, - imports: [MatButtonModule, NgIf, MatIconModule, SafePipe, TranslateModule], + imports: [ + MatButtonModule, + MatIcon, + NgIf, + MatIconModule, + SafePipe, + TranslateModule, + MatProgressSpinnerModule, + ], }) export class VodDetailsComponent implements OnInit { @Input({ required: true }) item: XtreamVodDetails; @@ -34,6 +43,7 @@ export class VodDetailsComponent implements OnInit { private portalId = this.route.snapshot.paramMap.get('id'); isFavorite = false; + isLoading = false; ngOnInit(): void { this.checkFavoriteStatus(); diff --git a/src/app/xtream/xtream-main-container.component.html b/src/app/xtream/xtream-main-container.component.html index 9e4eb12cb..305f41a7e 100644 --- a/src/app/xtream/xtream-main-container.component.html +++ b/src/app/xtream/xtream-main-container.component.html @@ -10,7 +10,10 @@ currentLayout === 'category' || currentLayout === 'category_content' " [sortVisible]="currentLayout === 'category_content'" - [favoriteVisible]="currentLayout === 'category_content' || currentLayout === 'live-stream-favorites'" + [favoriteVisible]=" + currentLayout === 'category_content' || + currentLayout === 'live-stream-favorites' + " [contentTypeNavigationItems]="navigationContentTypes" [activeLiveStream]="activeLiveStream" [favoritesLiveStream$]="favoritesLiveStream$" diff --git a/src/app/xtream/xtream-main-container.component.ts b/src/app/xtream/xtream-main-container.component.ts index b43f0ee2c..8e33b1776 100644 --- a/src/app/xtream/xtream-main-container.component.ts +++ b/src/app/xtream/xtream-main-container.component.ts @@ -21,10 +21,10 @@ 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, + REMOTE_CONTROL_CHANGE_CHANNEL, XTREAM_REQUEST, XTREAM_RESPONSE, } from '../../../shared/ipc-commands'; @@ -195,9 +195,12 @@ 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); - }) + new IpcCommand( + REMOTE_CONTROL_CHANGE_CHANNEL, + (response: { type: 'up' | 'down' }) => { + this.remoteControlChangeChannel(response.type); + } + ), ]; constructor() { @@ -207,16 +210,16 @@ export class XtreamMainContainerComponent implements OnInit { this.favorites$ = this.playlistService.getPortalFavorites( this.currentPlaylist()._id ); - this.favoritesLiveStream$ = this.playlistService.getPortalLiveStreamFavorites( - this.currentPlaylist()._id - ); + this.favoritesLiveStream$ = + this.playlistService.getPortalLiveStreamFavorites( + this.currentPlaylist()._id + ); } }); } ngOnInit() { this.setInitialBreadcrumb(); - this.commandsList.forEach((command) => { if (this.dataService.isElectron) { this.dataService.listenOn(command.id, (_event, response) => @@ -274,6 +277,7 @@ export class XtreamMainContainerComponent implements OnInit { this.currentLayout = 'serie-details'; this.vodDetails = response.payload as XtreamSerieDetails; break; + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison case 'get_short_epg': this.epgItems = ( (response.payload as any).epg_listings as EpgItem[] @@ -368,12 +372,14 @@ export class XtreamMainContainerComponent implements OnInit { this.dialog.open(ExternalPlayerInfoDialogComponent); this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl, + mpvPlayerPath: this.settings()?.mpvPlayerPath, }); } else if (this.player === VideoPlayer.VLC) { if (!this.hideExternalInfoDialog()) this.dialog.open(ExternalPlayerInfoDialogComponent); this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: streamUrl, + vlcPlayerPath: this.settings()?.vlcPlayerPath, }); } else { if (this.selectedContentType !== ContentType.ITV) { @@ -548,7 +554,10 @@ export class XtreamMainContainerComponent implements OnInit { } remoteControlChangeChannel(type: 'up' | 'down') { - if (this.currentLayout === 'category_content' && this.activeLiveStream) { + if ( + this.currentLayout === 'category_content' && + this.activeLiveStream + ) { let nextItem; const index = this.activeLiveStream.num - 1; if (type === 'up') {