refactor: tauri support for mpv/vlc

This commit is contained in:
4gray committed 2024-11-15 23:18:54 +01:00
1 parent 4d8bac9645
commit 9f1b123078
17 files changed
+389 -42

No files matched your search

+84
View File
@@ -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(
+9 -3
View File
@@ -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);
}
+1 -1
View File
@@ -13,7 +13,7 @@
}
.recent-playlists {
height: calc(100vh - 106px);
height: calc(100vh - 100px);
overflow: auto;
}
@@ -19,7 +19,7 @@
/>
</mat-form-field>
</div>
<mat-nav-list style="overflow: hidden">
<mat-nav-list id="all-channels">
<cdk-virtual-scroll-viewport
itemSize="50"
class="scroll-viewport"
@@ -5,7 +5,7 @@
}
.scroll-viewport {
min-height: calc(100vh - 185px);
min-height: calc(100vh - 159px);
}
.search-panel {
@@ -21,7 +21,11 @@
mat-nav-list {
overflow: scroll;
max-height: calc(100vh - 193px);
}
#all-channels {
overflow: hidden;
max-height: calc(100vh - 167px);
}
::ng-deep {
@@ -61,7 +65,7 @@ mat-nav-list {
#groups-list,
#favorites-list {
height: calc(100vh - 120px);
height: calc(100vh - 105px);
}
@media only screen and (max-width: 599px) {
+2 -1
View File
@@ -10,10 +10,11 @@ export abstract class DataService {
return this.isElectron ? window.require('electron').ipcRenderer : null;
}
abstract getAppVersion(): string;
abstract sendIpcEvent(type: string, payload?: unknown): void;
abstract sendIpcEvent(type: string, payload?: unknown);
abstract removeAllListeners(type: string): void;
abstract listenOn(
command: string,
callback: (...args: any[]) => void
): void;
abstract getAppEnvironment(): string;
}
+4
View File
@@ -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';
}
}
+4
View File
@@ -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';
}
}
+197
View File
@@ -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<string, string> }
);
} else if (type === 'STALKER_REQUEST') {
console.log('STALKER_REQUEST');
this.fetchStalkerData(
payload as {
url: string;
macAddress: string;
params: Record<string, string>;
}
);
} 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<string, string>;
}) {
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<Playlist>) {
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<string, string>;
}) {
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';
}
}
+20 -14
View File
@@ -199,24 +199,30 @@
></mat-checkbox>
</div>
</div>
<mat-divider></mat-divider>
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
@if (isPwa || isElectron) {
<mat-divider></mat-divider>
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
<p>
{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}
</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-checkbox
class="column"
formControlName="remoteControl"
></mat-checkbox>
</div>
</div>
<div class="column" style="margin-right: 10px">
<mat-checkbox
class="column"
formControlName="remoteControl"
></mat-checkbox>
</div>
</div>
}
<mat-divider></mat-divider>
<div class="row" *ngIf="settingsForm.value.remoteControl === true">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}
<p>{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}</p>
<p>
{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}
</p>
</div>
<div class="column" style="margin-right: 10px">
<mat-form-field appearance="outline" class="full-width">
@@ -294,7 +300,7 @@
</div>
</div>
<div class="row" *ngIf="!isElectron">
<div class="row" *ngIf="isPwa">
{{ 'SETTINGS.EPG_NOTE' | translate }}
&nbsp;<a href="https://github.com/4gray/iptvnator/releases">{{
'SETTINGS.EPG_NOTE_URL_TEXT' | translate
+12 -5
View File
@@ -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,
@@ -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<void> {
if (this.isTauri) {
await open(url);
} else {
window.open(url, '_blank');
}
}
/**
@@ -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<PlayerDialogComponent, PlayerDialogData>(
+2
View File
@@ -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,
});
}
);
@@ -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();
@@ -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$"
@@ -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') {