2025-02-15 00:37:32 -08:00
|
|
|
import { ChildProcess, spawn } from "child_process";
|
|
|
|
|
import { Socket } from "net";
|
|
|
|
|
import { WebSocket } from "ws";
|
2025-02-15 21:40:30 -08:00
|
|
|
import { getLinkPreview } from "link-preview-js";
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
interface PendingCommand {
|
|
|
|
|
resolve: (value: any) => void;
|
|
|
|
|
reject: (reason: any) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 21:40:30 -08:00
|
|
|
interface LinkMetadata {
|
|
|
|
|
title?: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
siteName?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
export class MediaPlayer {
|
|
|
|
|
private playerProcess: ChildProcess;
|
|
|
|
|
private socket: Socket;
|
|
|
|
|
private eventSubscribers: WebSocket[] = [];
|
|
|
|
|
|
|
|
|
|
private pendingCommands: Map<number, PendingCommand> = new Map();
|
|
|
|
|
private requestId: number = 1;
|
|
|
|
|
private dataBuffer: string = '';
|
2025-02-15 21:40:30 -08:00
|
|
|
private metadata: Map<string, LinkMetadata> = new Map();
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
constructor() {
|
2025-02-15 02:19:14 -08:00
|
|
|
const socketFilename = Math.random().toString(36).substring(2, 10);
|
|
|
|
|
const socketPath = `/tmp/mpv-${socketFilename}`;
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
console.log("Starting player process");
|
|
|
|
|
this.playerProcess = spawn("mpv", [
|
|
|
|
|
"--no-video",
|
|
|
|
|
"--no-terminal",
|
|
|
|
|
"--idle=yes",
|
2025-02-15 02:19:14 -08:00
|
|
|
"--input-ipc-server=" + socketPath
|
2025-02-15 00:37:32 -08:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
this.socket = new Socket();
|
|
|
|
|
|
|
|
|
|
this.playerProcess.on("spawn", () => {
|
2025-02-15 02:19:14 -08:00
|
|
|
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
2025-02-15 00:37:32 -08:00
|
|
|
setTimeout(() => {
|
2025-02-15 02:19:14 -08:00
|
|
|
this.connectToSocket(socketPath);
|
2025-02-15 12:15:41 -08:00
|
|
|
}, 500);
|
2025-02-15 00:37:32 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getPlaylist(): Promise<any> {
|
|
|
|
|
return this.writeCommand("get_property", ["playlist"])
|
|
|
|
|
.then((response) => {
|
2025-02-15 21:40:30 -08:00
|
|
|
// Enhance playlist items with metadata
|
|
|
|
|
const playlist = response.data;
|
|
|
|
|
return playlist.map((item: any) => ({
|
|
|
|
|
...item,
|
|
|
|
|
metadata: this.metadata.get(item.filename) || {}
|
|
|
|
|
}));
|
2025-02-15 00:37:32 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getNowPlaying(): Promise<string> {
|
|
|
|
|
return this.writeCommand("get_property", ["media-title"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 02:37:17 -08:00
|
|
|
public async getCurrentFile(): Promise<string> {
|
|
|
|
|
return this.writeCommand("get_property", ["stream-open-filename"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
public async getPauseState(): Promise<boolean> {
|
|
|
|
|
return this.writeCommand("get_property", ["pause"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getVolume(): Promise<number> {
|
|
|
|
|
return this.writeCommand("get_property", ["volume"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getIdle(): Promise<boolean> {
|
|
|
|
|
return this.writeCommand("get_property", ["idle"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async append(url: string) {
|
2025-02-15 21:40:30 -08:00
|
|
|
const result = await this.modify(() => this.writeCommand("loadfile", [url, "append-play"]));
|
|
|
|
|
|
|
|
|
|
// Asynchronously fetch the metadata for this after we update the playlist
|
|
|
|
|
this.fetchMetadataAndNotify(url).catch(error => {
|
|
|
|
|
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async play() {
|
2025-02-15 02:37:17 -08:00
|
|
|
return this.modify(() => this.writeCommand("set_property", ["pause", false]));
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async pause() {
|
2025-02-15 02:37:17 -08:00
|
|
|
return this.modify(() => this.writeCommand("set_property", ["pause", true]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async skip() {
|
|
|
|
|
return this.modify(() => this.writeCommand("playlist-next", []));
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 16:28:47 -08:00
|
|
|
public async skipTo(index: number) {
|
|
|
|
|
return this.modify(() => this.writeCommand("playlist-play-index", [index]));
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 02:37:17 -08:00
|
|
|
public async previous() {
|
|
|
|
|
return this.modify(() => this.writeCommand("playlist-prev", []));
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async deletePlaylistItem(index: number) {
|
2025-02-15 02:37:17 -08:00
|
|
|
return this.modify(() => this.writeCommand("playlist-remove", [index]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async setVolume(volume: number) {
|
|
|
|
|
return this.modify(() => this.writeCommand("set_property", ["volume", volume]));
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public subscribe(ws: WebSocket) {
|
|
|
|
|
this.eventSubscribers.push(ws);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public unsubscribe(ws: WebSocket) {
|
|
|
|
|
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
|
|
|
|
|
}
|
2025-02-15 02:37:17 -08:00
|
|
|
|
|
|
|
|
private async modify<T>(func: () => Promise<T>): Promise<T> {
|
|
|
|
|
return func()
|
|
|
|
|
.then((result) => {
|
|
|
|
|
// Notify all subscribers
|
|
|
|
|
this.handleEvent("user_modify", {});
|
|
|
|
|
return result;
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
private async writeCommand(command: string, args: any[]): Promise<any> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const id = this.requestId++;
|
|
|
|
|
|
|
|
|
|
const commandObject = JSON.stringify({
|
|
|
|
|
command: [command, ...args],
|
|
|
|
|
request_id: id
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.pendingCommands.set(id, { resolve, reject });
|
|
|
|
|
this.socket.write(commandObject + '\n');
|
|
|
|
|
|
|
|
|
|
// Add timeout to prevent hanging promises
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (this.pendingCommands.has(id)) {
|
|
|
|
|
const pending = this.pendingCommands.get(id);
|
|
|
|
|
if (pending) {
|
|
|
|
|
pending.reject(new Error('Command timed out'));
|
|
|
|
|
this.pendingCommands.delete(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, 5000);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 21:40:30 -08:00
|
|
|
private async fetchMetadataAndNotify(url: string) {
|
|
|
|
|
try {
|
|
|
|
|
const metadata = await getLinkPreview(url);
|
|
|
|
|
this.metadata.set(url, {
|
|
|
|
|
title: (metadata as any)?.title,
|
|
|
|
|
description: (metadata as any)?.description,
|
|
|
|
|
siteName: (metadata as any)?.siteName,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Notify clients that metadata has been updated
|
|
|
|
|
this.handleEvent("metadata_update", {
|
|
|
|
|
url,
|
|
|
|
|
metadata: this.metadata.get(url)
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 02:19:14 -08:00
|
|
|
private connectToSocket(path: string) {
|
|
|
|
|
this.socket.connect(path);
|
2025-02-15 00:37:32 -08:00
|
|
|
this.socket.on("data", data => this.receiveData(data.toString()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleEvent(event: string, data: any) {
|
2025-02-15 02:19:14 -08:00
|
|
|
console.log("MPV Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
// Notify all subscribers
|
|
|
|
|
this.eventSubscribers.forEach(subscriber => {
|
|
|
|
|
subscriber.send(JSON.stringify({ event, data }));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private receiveData(data: string) {
|
|
|
|
|
this.dataBuffer += data;
|
|
|
|
|
|
|
|
|
|
const lines = this.dataBuffer.split('\n');
|
|
|
|
|
|
|
|
|
|
// Keep last incomplete line in the buffer
|
|
|
|
|
this.dataBuffer = lines.pop() || '';
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (line.trim().length > 0) {
|
|
|
|
|
try {
|
|
|
|
|
const response = JSON.parse(line);
|
|
|
|
|
if (response.request_id) {
|
|
|
|
|
const pending = this.pendingCommands.get(response.request_id);
|
|
|
|
|
if (pending) {
|
|
|
|
|
pending.resolve(response);
|
|
|
|
|
this.pendingCommands.delete(response.request_id);
|
|
|
|
|
}
|
|
|
|
|
} else if (response.event) {
|
2025-02-15 02:19:14 -08:00
|
|
|
this.handleEvent(response.event, response);
|
2025-02-15 00:37:32 -08:00
|
|
|
} else {
|
|
|
|
|
console.log(response);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error parsing JSON:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|