/* * MediaPlayer.ts * Copyleft 2025 James Magahern * * 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 . */ import { ChildProcess, spawn } from "child_process"; import { Socket } from "net"; import { WebSocket } from "ws"; import { getLinkPreview } from "link-preview-js"; import { PlaylistItem, LinkMetadata } from './types'; import { FavoritesStore } from "./FavoritesStore"; import { Bonjour } from "bonjour-service"; import os from 'os'; interface PendingCommand { resolve: (value: any) => void; reject: (reason: any) => void; } enum UserEvent { PlaylistUpdate = "playlist_update", NowPlayingUpdate = "now_playing_update", VolumeUpdate = "volume_update", FavoritesUpdate = "favorites_update", MetadataUpdate = "metadata_update", MPDUpdate = "mpd_update", PlaybackError = "playback_error", } export interface Features { video: boolean; screenshare: boolean; browserPlayback: boolean; } export class MediaPlayer { private playerProcess: ChildProcess | null = null; private socket: Promise; private eventSubscribers: WebSocket[] = []; private favoritesStore: FavoritesStore; private pendingCommands: Map = new Map(); private requestId: number = 1; private dataBuffer: string = ''; private metadata: Map = new Map(); private bonjourInstance: Bonjour | null = null; private playbackErrors: Map = new Map(); private currentFile: string | null = null; private lastLoadCandidate: string | null = null; constructor() { this.socket = this.tryRespawnPlayerProcess(); this.favoritesStore = new FavoritesStore(); this.favoritesStore.onFavoritesChanged = (favorites) => { this.handleEvent(UserEvent.FavoritesUpdate, { favorites }); }; this.getFeatures().then(features => { console.log("Features: ", features); }); } public startZeroconfService(port: number) { if (this.bonjourInstance) { console.log("Zeroconf service already running"); return; } this.bonjourInstance = new Bonjour(); const service = this.bonjourInstance.publish({ name: `QueueCube Media Server (${os.hostname()})`, 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"); } } private tryRespawnPlayerProcess(): Promise { const socketFilename = Math.random().toString(36).substring(2, 10); const socketPath = `/tmp/mpv-${socketFilename}`; const enableVideo = process.env.ENABLE_VIDEO || false; const logfilePath = `/tmp/mpv-logfile.txt`; console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")"); this.playerProcess = spawn("mpv", [ "--video=" + (enableVideo ? "auto" : "no"), "--fullscreen", "--no-terminal", "--idle=yes", "--input-ipc-server=" + socketPath, "--log-file=" + logfilePath, "--msg-level=all=v" ]); let socketReady!: (s: Socket) => void; let socketPromise = new Promise(resolve => { socketReady = resolve; }); this.playerProcess.on("spawn", () => { console.log(`Player process spawned, opening socket @ ${socketPath}`); setTimeout(() => { let socket = this.connectToSocket(socketPath); socketReady(socket); }, 500); }); this.playerProcess.on("error", (error) => { console.error("Player process error:", error); console.log("Continuing without mpv player..."); }); return socketPromise; } public async getPlaylist(): Promise { return this.writeCommand("get_property", ["playlist"]) .then((response) => { // Enhance playlist items with metadata const playlist = response.data as PlaylistItem[]; return playlist.map((item: PlaylistItem) => ({ ...item, metadata: this.metadata.get(item.filename) || {}, playbackError: this.playbackErrors.get(item.filename) })); }); } public async getNowPlaying(): Promise { const playlist = await this.getPlaylist(); const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current); const fetchMediaTitle = async (): Promise => { try { return (await this.writeCommand("get_property", ["media-title"])).data; } catch (err) { return null; } }; if (currentlyPlayingSong !== undefined) { // Use media title if we don't have a title if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) { return { ...currentlyPlayingSong, title: await fetchMediaTitle() || currentlyPlayingSong.filename }; } return currentlyPlayingSong; } const mediaTitle = await fetchMediaTitle() || ""; return { id: 0, filename: mediaTitle, title: mediaTitle }; } public async getCurrentFile(): Promise { return this.writeCommand("get_property", ["stream-open-filename"]) .then((response) => { return response.data; }, (reject) => { return null; }); } public async getPauseState(): Promise { return this.writeCommand("get_property", ["pause"]) .then((response) => { return response.data; }); } public async getVolume(): Promise { return this.writeCommand("get_property", ["volume"]) .then((response) => { return response.data; }); } public async getTimePosition(): Promise { return this.writeCommand("get_property", ["time-pos"]) .then((response) => { return response.data; }, (rejected) => { return null; }); } public async getDuration(): Promise { return this.writeCommand("get_property", ["duration"]) .then((response) => { return response.data; }, (rejected) => { return null; }); } public async getSeekable(): Promise { return this.writeCommand("get_property", ["seekable"]) .then((response) => { return response.data; }, (rejected) => { return null; }); } public async getIdle(): Promise { return this.writeCommand("get_property", ["idle"]) .then((response) => { return response.data; }); } public async append(url: string) { await this.loadFile(url, "append-play"); } public async replace(url: string) { await this.loadFile(url, "replace"); } 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); } public async play() { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false])); } public async pause() { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true])); } public async stop() { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", [])); } public async skip() { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", [])); } public async skipTo(index: number) { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index])); } public async previous() { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", [])); } public async deletePlaylistItem(index: number) { return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index])); } public async setVolume(volume: number) { return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume])); } public async seek(time: number) { return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"])); } public subscribe(ws: WebSocket) { this.eventSubscribers.push(ws); } public unsubscribe(ws: WebSocket) { this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws); } public async getFavorites(): Promise { return this.favoritesStore.getFavorites(); } public async addFavorite(filename: string) { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename)); } public async removeFavorite(filename: string) { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename)); } public async clearFavorites() { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites()); } public async updateFavoriteTitle(filename: string, title: string) { return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title)); } public async getFeatures(): Promise { return { video: !!process.env.ENABLE_VIDEO, screenshare: !!process.env.ENABLE_SCREENSHARE, browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK }; } private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) { this.lastLoadCandidate = url; this.playbackErrors.delete(url); this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')])); if (fetchMetadata) { this.fetchMetadataAndNotify(url).catch(error => { console.warn(`Failed to fetch metadata for ${url}:`, error); }); } } private async modify(event: UserEvent, func: () => Promise): Promise { return func() .then((result) => { // Notify all subscribers this.handleEvent(event, {}); return result; }, (reject) => { console.log("Error modifying playlist: " + reject); return reject; }); } private async writeCommand(command: string, args: any[]): Promise { // Wait for socket to become available. let socket = await this.socket; return new Promise((resolve, reject) => { const id = this.requestId++; const commandObject = JSON.stringify({ command: [command, ...args], request_id: id }); 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(); } // 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); }); } private async fetchMetadataAndNotify(url: string) { try { console.log("Fetching metadata for " + url); const metadata = await getLinkPreview(url); this.metadata.set(url, { title: (metadata as any)?.title, description: (metadata as any)?.description, siteName: (metadata as any)?.siteName, }); console.log("Metadata fetched for " + url); console.log(this.metadata.get(url)); // Notify clients that metadata has been updated this.handleEvent(UserEvent.MetadataUpdate, { url, metadata: this.metadata.get(url) }); } catch (error) { throw error; } } private connectToSocket(path: string): Socket { let socket = new Socket(); socket.connect(path); socket.on("data", data => this.receiveData(data.toString())); return socket; } private handleEvent(event: string, data: any) { console.log("Event [" + event + "]: ", data); // 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) { if (response.error == "success") { pending.resolve(response); } else { pending.reject(response.error); } this.pendingCommands.delete(response.request_id); } } else if (response.event) { 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 }); } this.handleEvent(UserEvent.MPDUpdate, response); } else { console.log(response); } } catch (error) { console.error('Error parsing JSON:', error); } } } } }