2025-04-24 16:24:22 -07:00
|
|
|
/*
|
|
|
|
|
* MediaPlayer.ts
|
|
|
|
|
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
|
|
|
|
*
|
|
|
|
|
* This program is free software: you can redistribute it and/or modify
|
|
|
|
|
* it under the terms of the GNU General Public License as published
|
|
|
|
|
* by the Free Software Foundation, either version 3 of the License,
|
|
|
|
|
* or (at your option) any later version.
|
|
|
|
|
*
|
|
|
|
|
* This program is distributed in the hope that it will be useful,
|
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
|
|
|
* General Public License for more details.
|
|
|
|
|
*
|
|
|
|
|
* You should have received a copy of the GNU General Public License
|
|
|
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
*/
|
|
|
|
|
|
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-23 13:46:31 -08:00
|
|
|
import { PlaylistItem, LinkMetadata } from './types';
|
|
|
|
|
import { FavoritesStore } from "./FavoritesStore";
|
2025-05-30 20:12:53 -07:00
|
|
|
import { Bonjour } from "bonjour-service";
|
2025-06-07 11:39:56 -07:00
|
|
|
import os from 'os';
|
2025-02-23 16:37:39 -08:00
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
interface PendingCommand {
|
|
|
|
|
resolve: (value: any) => void;
|
|
|
|
|
reject: (reason: any) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-23 16:37:39 -08:00
|
|
|
enum UserEvent {
|
|
|
|
|
PlaylistUpdate = "playlist_update",
|
|
|
|
|
NowPlayingUpdate = "now_playing_update",
|
|
|
|
|
VolumeUpdate = "volume_update",
|
|
|
|
|
FavoritesUpdate = "favorites_update",
|
|
|
|
|
MetadataUpdate = "metadata_update",
|
|
|
|
|
MPDUpdate = "mpd_update",
|
2025-11-15 17:58:29 -08:00
|
|
|
PlaybackError = "playback_error",
|
2025-02-23 16:37:39 -08:00
|
|
|
}
|
|
|
|
|
|
2025-06-24 12:50:15 -07:00
|
|
|
export interface Features {
|
|
|
|
|
video: boolean;
|
|
|
|
|
screenshare: boolean;
|
|
|
|
|
browserPlayback: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
export class MediaPlayer {
|
2025-05-30 16:40:20 -07:00
|
|
|
private playerProcess: ChildProcess | null = null;
|
|
|
|
|
private socket: Promise<Socket>;
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
private eventSubscribers: WebSocket[] = [];
|
2025-02-23 13:46:31 -08:00
|
|
|
private favoritesStore: FavoritesStore;
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
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-05-30 20:12:53 -07:00
|
|
|
private bonjourInstance: Bonjour | null = null;
|
2025-11-15 17:58:29 -08:00
|
|
|
private playbackErrors: Map<string, string> = new Map();
|
|
|
|
|
private currentFile: string | null = null;
|
|
|
|
|
private lastLoadCandidate: string | null = null;
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
constructor() {
|
2025-05-30 16:40:20 -07:00
|
|
|
this.socket = this.tryRespawnPlayerProcess();
|
|
|
|
|
|
|
|
|
|
this.favoritesStore = new FavoritesStore();
|
|
|
|
|
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
|
|
|
|
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
|
|
|
|
};
|
2025-06-24 12:50:15 -07:00
|
|
|
|
|
|
|
|
this.getFeatures().then(features => {
|
|
|
|
|
console.log("Features: ", features);
|
|
|
|
|
});
|
2025-05-30 16:40:20 -07:00
|
|
|
}
|
|
|
|
|
|
2025-05-30 20:12:53 -07:00
|
|
|
public startZeroconfService(port: number) {
|
|
|
|
|
if (this.bonjourInstance) {
|
|
|
|
|
console.log("Zeroconf service already running");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.bonjourInstance = new Bonjour();
|
|
|
|
|
|
|
|
|
|
const service = this.bonjourInstance.publish({
|
2025-06-07 11:39:56 -07:00
|
|
|
name: `QueueCube Media Server (${os.hostname()})`,
|
2025-05-30 20:12:53 -07:00
|
|
|
type: 'queuecube',
|
|
|
|
|
port: port,
|
|
|
|
|
txt: {
|
|
|
|
|
version: '1.0.0',
|
|
|
|
|
features: 'playlist,favorites,screenshare'
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
service.on('up', () => {
|
|
|
|
|
console.log(`Zeroconf service advertised: ${service.name} on port ${port}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
service.on('error', (err: Error) => {
|
|
|
|
|
console.error('Zeroconf service error:', err);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public stopZeroconfService() {
|
|
|
|
|
if (this.bonjourInstance) {
|
|
|
|
|
this.bonjourInstance.destroy();
|
|
|
|
|
this.bonjourInstance = null;
|
|
|
|
|
console.log("Zeroconf service stopped");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 16:40:20 -07:00
|
|
|
private tryRespawnPlayerProcess(): Promise<Socket> {
|
2025-02-15 02:19:14 -08:00
|
|
|
const socketFilename = Math.random().toString(36).substring(2, 10);
|
|
|
|
|
const socketPath = `/tmp/mpv-${socketFilename}`;
|
2025-02-23 18:17:27 -08:00
|
|
|
const enableVideo = process.env.ENABLE_VIDEO || false;
|
2025-05-30 16:40:20 -07:00
|
|
|
const logfilePath = `/tmp/mpv-logfile.txt`;
|
2025-02-15 02:19:14 -08:00
|
|
|
|
2025-02-23 18:17:27 -08:00
|
|
|
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
2025-02-15 00:37:32 -08:00
|
|
|
this.playerProcess = spawn("mpv", [
|
2025-02-23 18:17:27 -08:00
|
|
|
"--video=" + (enableVideo ? "auto" : "no"),
|
|
|
|
|
"--fullscreen",
|
2025-02-15 00:37:32 -08:00
|
|
|
"--no-terminal",
|
|
|
|
|
"--idle=yes",
|
2025-05-30 16:40:20 -07:00
|
|
|
"--input-ipc-server=" + socketPath,
|
|
|
|
|
"--log-file=" + logfilePath,
|
2025-11-15 15:45:34 -08:00
|
|
|
"--msg-level=all=v"
|
2025-02-15 00:37:32 -08:00
|
|
|
]);
|
|
|
|
|
|
2025-05-30 16:40:20 -07:00
|
|
|
|
|
|
|
|
let socketReady!: (s: Socket) => void;
|
|
|
|
|
let socketPromise = new Promise<Socket>(resolve => {
|
|
|
|
|
socketReady = resolve;
|
|
|
|
|
});
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
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-05-30 16:40:20 -07:00
|
|
|
let socket = this.connectToSocket(socketPath);
|
|
|
|
|
socketReady(socket);
|
2025-02-15 12:15:41 -08:00
|
|
|
}, 500);
|
2025-02-15 00:37:32 -08:00
|
|
|
});
|
2025-02-23 13:46:31 -08:00
|
|
|
|
2025-05-30 20:12:53 -07:00
|
|
|
this.playerProcess.on("error", (error) => {
|
|
|
|
|
console.error("Player process error:", error);
|
|
|
|
|
console.log("Continuing without mpv player...");
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-30 16:40:20 -07:00
|
|
|
return socketPromise;
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
2025-02-15 22:15:59 -08:00
|
|
|
public async getPlaylist(): Promise<PlaylistItem[]> {
|
2025-02-15 00:37:32 -08:00
|
|
|
return this.writeCommand("get_property", ["playlist"])
|
|
|
|
|
.then((response) => {
|
2025-02-15 21:40:30 -08:00
|
|
|
// Enhance playlist items with metadata
|
2025-02-15 22:15:59 -08:00
|
|
|
const playlist = response.data as PlaylistItem[];
|
|
|
|
|
return playlist.map((item: PlaylistItem) => ({
|
2025-02-15 21:40:30 -08:00
|
|
|
...item,
|
2025-11-15 17:58:29 -08:00
|
|
|
metadata: this.metadata.get(item.filename) || {},
|
|
|
|
|
playbackError: this.playbackErrors.get(item.filename)
|
2025-02-15 21:40:30 -08:00
|
|
|
}));
|
2025-02-15 00:37:32 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 22:15:59 -08:00
|
|
|
public async getNowPlaying(): Promise<PlaylistItem> {
|
|
|
|
|
const playlist = await this.getPlaylist();
|
|
|
|
|
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
2025-06-26 01:38:12 -07:00
|
|
|
const fetchMediaTitle = async (): Promise<string | null> => {
|
|
|
|
|
try {
|
|
|
|
|
return (await this.writeCommand("get_property", ["media-title"])).data;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2025-02-15 22:15:59 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (currentlyPlayingSong !== undefined) {
|
|
|
|
|
// Use media title if we don't have a title
|
|
|
|
|
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
|
|
|
|
return {
|
|
|
|
|
...currentlyPlayingSong,
|
2025-06-26 01:38:12 -07:00
|
|
|
title: await fetchMediaTitle() || currentlyPlayingSong.filename
|
2025-02-15 22:15:59 -08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return currentlyPlayingSong;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-26 01:38:12 -07:00
|
|
|
const mediaTitle = await fetchMediaTitle() || "";
|
2025-02-15 22:15:59 -08:00
|
|
|
return {
|
|
|
|
|
id: 0,
|
|
|
|
|
filename: mediaTitle,
|
|
|
|
|
title: mediaTitle
|
|
|
|
|
};
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
2025-06-26 01:38:12 -07:00
|
|
|
public async getCurrentFile(): Promise<string | null> {
|
2025-02-15 02:37:17 -08:00
|
|
|
return this.writeCommand("get_property", ["stream-open-filename"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
2025-06-26 01:38:12 -07:00
|
|
|
}, (reject) => { return null; });
|
2025-02-15 02:37:17 -08:00
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-26 01:38:12 -07:00
|
|
|
public async getTimePosition(): Promise<number | null> {
|
|
|
|
|
return this.writeCommand("get_property", ["time-pos"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
}, (rejected) => { return null; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getDuration(): Promise<number | null> {
|
|
|
|
|
return this.writeCommand("get_property", ["duration"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
}, (rejected) => { return null; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getSeekable(): Promise<boolean | null> {
|
|
|
|
|
return this.writeCommand("get_property", ["seekable"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
}, (rejected) => { return null; });
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
public async getIdle(): Promise<boolean> {
|
|
|
|
|
return this.writeCommand("get_property", ["idle"])
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return response.data;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async append(url: string) {
|
2025-02-23 13:46:31 -08:00
|
|
|
await this.loadFile(url, "append-play");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async replace(url: string) {
|
|
|
|
|
await this.loadFile(url, "replace");
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
2025-05-12 20:27:09 -07:00
|
|
|
public async initiateScreenSharing(url: string) {
|
|
|
|
|
console.log(`Initiating screen sharing with file: ${url}`);
|
|
|
|
|
|
|
|
|
|
this.metadata.set(url, {
|
|
|
|
|
title: "Screen Sharing",
|
|
|
|
|
description: "Screen Sharing",
|
|
|
|
|
siteName: "Screen Sharing",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Special options for mpv to better handle screen sharing (AI recommended...)
|
|
|
|
|
await this.loadFile(url, "replace", false, [
|
|
|
|
|
"demuxer-lavf-o=fflags=+nobuffer+discardcorrupt", // Reduce buffering and discard corrupt frames
|
|
|
|
|
"demuxer-lavf-o=analyzeduration=100000", // Reduce analyze duration
|
|
|
|
|
"demuxer-lavf-o=probesize=1000000", // Reduce probe size
|
|
|
|
|
"untimed=yes", // Ignore timing info
|
|
|
|
|
"cache=no", // Disable cache
|
|
|
|
|
"force-seekable=yes", // Force seekable
|
|
|
|
|
"no-cache=yes", // Disable cache
|
|
|
|
|
"demuxer-max-bytes=500K", // Limit demuxer buffer
|
|
|
|
|
"demuxer-readahead-secs=0.1", // Reduce readahead
|
|
|
|
|
"hr-seek=no", // Disable high-res seeking
|
|
|
|
|
"video-sync=display-resample", // Better sync mode
|
|
|
|
|
"video-latency-hacks=yes", // Enable latency hacks
|
|
|
|
|
"audio-sync=yes", // Enable audio sync
|
|
|
|
|
"audio-buffer=0.1", // Reduce audio buffer
|
|
|
|
|
"audio-channels=stereo", // Force stereo audio
|
|
|
|
|
"audio-samplerate=44100", // Match sample rate
|
|
|
|
|
"audio-format=s16", // Use 16-bit audio
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Make sure it's playing
|
|
|
|
|
setTimeout(() => this.play(), 100);
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
public async play() {
|
2025-02-23 16:37:39 -08:00
|
|
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async pause() {
|
2025-02-23 16:37:39 -08:00
|
|
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
|
2025-02-15 02:37:17 -08:00
|
|
|
}
|
|
|
|
|
|
2025-04-20 01:23:34 -07:00
|
|
|
public async stop() {
|
|
|
|
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 02:37:17 -08:00
|
|
|
public async skip() {
|
2025-02-23 16:37:39 -08:00
|
|
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
2025-02-15 02:37:17 -08:00
|
|
|
}
|
|
|
|
|
|
2025-02-15 16:28:47 -08:00
|
|
|
public async skipTo(index: number) {
|
2025-02-23 16:37:39 -08:00
|
|
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
2025-02-15 16:28:47 -08:00
|
|
|
}
|
|
|
|
|
|
2025-02-15 02:37:17 -08:00
|
|
|
public async previous() {
|
2025-02-23 16:37:39 -08:00
|
|
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async deletePlaylistItem(index: number) {
|
2025-02-23 16:37:39 -08:00
|
|
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
|
2025-02-15 02:37:17 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async setVolume(volume: number) {
|
2025-02-23 16:37:39 -08:00
|
|
|
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
2025-06-26 01:38:12 -07:00
|
|
|
public async seek(time: number) {
|
|
|
|
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"]));
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
2025-02-23 13:46:31 -08:00
|
|
|
public async getFavorites(): Promise<PlaylistItem[]> {
|
|
|
|
|
return this.favoritesStore.getFavorites();
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-23 18:04:14 -08:00
|
|
|
public async addFavorite(filename: string) {
|
2025-03-05 00:24:19 -08:00
|
|
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
|
2025-02-23 13:46:31 -08:00
|
|
|
}
|
|
|
|
|
|
2025-02-23 17:58:26 -08:00
|
|
|
public async removeFavorite(filename: string) {
|
2025-03-05 00:24:19 -08:00
|
|
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
|
2025-02-23 13:46:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async clearFavorites() {
|
2025-03-05 00:24:19 -08:00
|
|
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
|
2025-02-23 13:46:31 -08:00
|
|
|
}
|
|
|
|
|
|
2025-03-05 00:10:23 -08:00
|
|
|
public async updateFavoriteTitle(filename: string, title: string) {
|
2025-03-05 00:24:19 -08:00
|
|
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
|
2025-03-05 00:10:23 -08:00
|
|
|
}
|
|
|
|
|
|
2025-06-24 12:50:15 -07:00
|
|
|
public async getFeatures(): Promise<Features> {
|
|
|
|
|
return {
|
|
|
|
|
video: !!process.env.ENABLE_VIDEO,
|
|
|
|
|
screenshare: !!process.env.ENABLE_SCREENSHARE,
|
|
|
|
|
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-12 20:27:09 -07:00
|
|
|
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
2025-11-15 17:58:29 -08:00
|
|
|
this.lastLoadCandidate = url;
|
|
|
|
|
this.playbackErrors.delete(url);
|
2025-05-30 16:40:20 -07:00
|
|
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
2025-02-23 13:46:31 -08:00
|
|
|
|
2025-05-12 20:27:09 -07:00
|
|
|
if (fetchMetadata) {
|
|
|
|
|
this.fetchMetadataAndNotify(url).catch(error => {
|
|
|
|
|
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-02-23 13:46:31 -08:00
|
|
|
}
|
|
|
|
|
|
2025-02-23 16:37:39 -08:00
|
|
|
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
2025-02-15 02:37:17 -08:00
|
|
|
return func()
|
|
|
|
|
.then((result) => {
|
|
|
|
|
// Notify all subscribers
|
2025-02-23 16:37:39 -08:00
|
|
|
this.handleEvent(event, {});
|
2025-02-15 02:37:17 -08:00
|
|
|
return result;
|
2025-06-26 01:38:12 -07:00
|
|
|
}, (reject) => {
|
|
|
|
|
console.log("Error modifying playlist: " + reject);
|
|
|
|
|
return reject;
|
|
|
|
|
});
|
2025-02-15 02:37:17 -08:00
|
|
|
}
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
private async writeCommand(command: string, args: any[]): Promise<any> {
|
2025-05-30 16:40:20 -07:00
|
|
|
// Wait for socket to become available.
|
|
|
|
|
let socket = await this.socket;
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const id = this.requestId++;
|
|
|
|
|
|
|
|
|
|
const commandObject = JSON.stringify({
|
|
|
|
|
command: [command, ...args],
|
|
|
|
|
request_id: id
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-30 16:40:20 -07:00
|
|
|
try {
|
|
|
|
|
this.pendingCommands.set(id, { resolve, reject });
|
|
|
|
|
socket.write(commandObject + '\n');
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
|
|
|
|
|
this.tryRespawnPlayerProcess();
|
|
|
|
|
}
|
2025-02-15 00:37:32 -08:00
|
|
|
|
|
|
|
|
// 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 {
|
2025-02-23 13:46:31 -08:00
|
|
|
console.log("Fetching metadata for " + url);
|
2025-02-15 21:40:30 -08:00
|
|
|
const metadata = await getLinkPreview(url);
|
|
|
|
|
this.metadata.set(url, {
|
|
|
|
|
title: (metadata as any)?.title,
|
|
|
|
|
description: (metadata as any)?.description,
|
|
|
|
|
siteName: (metadata as any)?.siteName,
|
|
|
|
|
});
|
2025-02-23 13:46:31 -08:00
|
|
|
|
|
|
|
|
console.log("Metadata fetched for " + url);
|
|
|
|
|
console.log(this.metadata.get(url));
|
2025-02-15 21:40:30 -08:00
|
|
|
|
|
|
|
|
// Notify clients that metadata has been updated
|
2025-02-23 16:37:39 -08:00
|
|
|
this.handleEvent(UserEvent.MetadataUpdate, {
|
2025-02-15 21:40:30 -08:00
|
|
|
url,
|
|
|
|
|
metadata: this.metadata.get(url)
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-30 16:40:20 -07:00
|
|
|
private connectToSocket(path: string): Socket {
|
|
|
|
|
let socket = new Socket();
|
|
|
|
|
socket.connect(path);
|
|
|
|
|
socket.on("data", data => this.receiveData(data.toString()));
|
|
|
|
|
|
|
|
|
|
return socket;
|
2025-02-15 00:37:32 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleEvent(event: string, data: any) {
|
2025-03-05 00:10:23 -08:00
|
|
|
console.log("Event [" + event + "]: ", data);
|
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) {
|
2025-06-26 01:38:12 -07:00
|
|
|
if (response.error == "success") {
|
|
|
|
|
pending.resolve(response);
|
|
|
|
|
} else {
|
|
|
|
|
pending.reject(response.error);
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-15 00:37:32 -08:00
|
|
|
this.pendingCommands.delete(response.request_id);
|
|
|
|
|
}
|
|
|
|
|
} else if (response.event) {
|
2025-11-15 17:58:29 -08:00
|
|
|
if (response.event === "start-file") {
|
|
|
|
|
// Clear any previous error for the file that is starting
|
|
|
|
|
const file = response.file || this.lastLoadCandidate;
|
|
|
|
|
if (file) {
|
|
|
|
|
this.currentFile = file;
|
|
|
|
|
this.playbackErrors.delete(file);
|
|
|
|
|
}
|
|
|
|
|
} else if (response.event === "end-file" && response.reason === "error") {
|
|
|
|
|
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
|
|
|
|
|
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
|
|
|
|
|
|
|
|
|
|
this.playbackErrors.set(file, errorMessage);
|
|
|
|
|
this.handleEvent(UserEvent.PlaybackError, {
|
|
|
|
|
filename: file,
|
|
|
|
|
error: errorMessage
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-23 16:37:39 -08:00
|
|
|
this.handleEvent(UserEvent.MPDUpdate, response);
|
2025-02-15 00:37:32 -08:00
|
|
|
} else {
|
|
|
|
|
console.log(response);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error parsing JSON:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-05 00:10:23 -08:00
|
|
|
}
|