Feature: adds screen sharing

This commit is contained in:
2025-05-12 20:27:09 -07:00
parent db3b10d324
commit 035d74d412
6 changed files with 345 additions and 23 deletions

View File

@@ -152,6 +152,40 @@ export class MediaPlayer {
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]));
}
@@ -212,12 +246,14 @@ export class MediaPlayer {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
}
private async loadFile(url: string, mode: string) {
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode]));
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, options.join(',')]));
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, error);
});
if (fetchMetadata) {
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, error);
});
}
}
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {

View File

@@ -19,9 +19,13 @@
import express from "express";
import expressWs from "express-ws";
import path from "path";
import fs from "fs";
import os from "os";
import { MediaPlayer } from "./MediaPlayer";
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
import { PlaylistItem } from './types';
import { PassThrough } from "stream";
import { AddressInfo } from "net";
const app = express();
app.use(express.json());
@@ -30,6 +34,10 @@ expressWs(app);
const apiRouter = express.Router();
const mediaPlayer = new MediaPlayer();
// Create a shared stream that both endpoints can access
let activeScreenshareStream: PassThrough | null = null;
let activeScreenshareMimeType: string | null = null;
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
return async (req: any, res: any) => {
try {
@@ -127,6 +135,82 @@ apiRouter.ws("/events", (ws, req) => {
});
});
// This is effectively a "private" endpoint that only the MPV instance accesses. We're
// using the fact that QueueCube/MPV is based all around streaming URLs, so the active
// screenshare stream manifests as just another URL to play.
apiRouter.get("/screenshareStream", withErrorHandling(async (req, res) => {
res.setHeader("Content-Type", activeScreenshareMimeType || "video/mp4");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.setHeader("Connection", "keep-alive");
res.setHeader("Transfer-Encoding", "chunked");
if (!activeScreenshareStream) {
res.status(503).send("No active screen sharing session");
return;
}
// Handle client disconnection
req.on('close', () => {
console.log("Screenshare viewer disconnected");
});
// Configure stream for low latency
activeScreenshareStream.setMaxListeners(0);
// Pipe with immediate flush
activeScreenshareStream.pipe(res, { end: false });
}));
apiRouter.ws("/screenshare", (ws, req) => {
const mimeType = req.query.mimeType as string;
console.log("Screen sharing client connected with mimeType: " + mimeType);
ws.binaryType = "arraybuffer";
let firstChunk = false;
// Configure WebSocket for low latency
ws.setMaxListeners(0);
ws.binaryType = "arraybuffer";
ws.on('message', (data: any) => {
const buffer = data instanceof Buffer ? data : Buffer.from(data);
if (!firstChunk) {
firstChunk = true;
const port = (server.address() as AddressInfo).port;
const url = `http://localhost:${port}/api/screenshareStream`;
console.log(`Starting screen share stream at ${url}`);
// Create new shared stream with immediate flush
activeScreenshareStream = new PassThrough({
highWaterMark: 1024 * 1024, // 1MB buffer
allowHalfOpen: false
});
activeScreenshareStream.write(buffer);
mediaPlayer.initiateScreenSharing(url);
} else if (activeScreenshareStream) {
// Write with immediate flush
activeScreenshareStream.write(buffer, () => {
activeScreenshareStream?.cork();
activeScreenshareStream?.uncork();
});
}
});
ws.on('close', () => {
console.log("Screen sharing client disconnected");
if (activeScreenshareStream) {
activeScreenshareStream.end();
activeScreenshareStream = null;
}
mediaPlayer.stop();
});
});
apiRouter.get("/search", withErrorHandling(async (req, res) => {
const query = req.query.q as string;
if (!query) {

View File

@@ -53,6 +53,7 @@ export enum ServerEvent {
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
ScreenShare = "screen_share",
}
export const API = {
@@ -91,6 +92,10 @@ export const API = {
await fetch('/api/play', { method: 'POST' });
},
async stop(): Promise<void> {
await fetch('/api/stop', { method: 'POST' });
},
async pause(): Promise<void> {
await fetch('/api/pause', { method: 'POST' });
},
@@ -168,5 +173,11 @@ export const API = {
},
body: JSON.stringify({ title }),
});
},
startScreenShare(mimeType: string): WebSocket {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${protocol}://${window.location.host}/api/screenshare?mimeType=${mimeType}`);
return ws;
}
};

View File

@@ -8,6 +8,7 @@ import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
import useWebSocket from 'react-use-websocket';
import classNames from 'classnames';
import { useScreenShare } from '../hooks/useScreenShare';
enum Tabs {
Playlist = "playlist",
@@ -82,6 +83,7 @@ const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, au
const App: React.FC = () => {
const [isPlaying, setIsPlaying] = useState(false);
const [isIdle, setIsIdle] = useState(false);
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
const [volume, setVolume] = useState(100);
@@ -92,6 +94,13 @@ const App: React.FC = () => {
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
const {
isScreenSharing,
isScreenSharingSupported,
toggleScreenShare,
stopScreenShare
} = useScreenShare();
const fetchPlaylist = useCallback(async () => {
const playlist = await API.getPlaylist();
setPlaylist(playlist);
@@ -114,6 +123,7 @@ const App: React.FC = () => {
setNowPlayingFileName(nowPlaying.playingItem.filename);
setIsPlaying(!nowPlaying.isPaused);
setVolume(nowPlaying.volume);
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
}, [volumeSettingIsLocked]);
const handleAddURL = async (url: string) => {
@@ -143,6 +153,13 @@ const App: React.FC = () => {
fetchNowPlaying();
};
const handleStop = async () => {
stopScreenShare();
await API.stop();
fetchNowPlaying();
};
const handleSkip = async () => {
await API.skip();
fetchNowPlaying();
@@ -301,13 +318,18 @@ const App: React.FC = () => {
songName={nowPlayingSong || "(Not Playing)"}
fileName={nowPlayingFileName || ""}
isPlaying={isPlaying}
isIdle={isIdle}
onPlayPause={togglePlayPause}
onStop={handleStop}
onSkip={handleSkip}
onPrevious={handlePrevious}
onScreenShare={toggleScreenShare}
isScreenSharing={isScreenSharing}
volume={volume}
onVolumeSettingChange={handleVolumeSettingChange}
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
isScreenSharingSupported={isScreenSharingSupported}
/>
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>

View File

@@ -1,16 +1,22 @@
import React, { HTMLAttributes } from 'react';
import classNames from 'classnames';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp } from 'react-icons/fa';
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
songName: string;
fileName: string;
isPlaying: boolean;
isIdle: boolean;
volume: number;
onPlayPause: () => void;
onStop: () => void;
onSkip: () => void;
onPrevious: () => void;
onScreenShare: () => void;
isScreenSharingSupported: boolean;
isScreenSharing: boolean;
// Sent when the volume setting actually changes value
onVolumeSettingChange: (volume: number) => void;
@@ -22,16 +28,27 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
}
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
const titleArea = props.isScreenSharing ? (
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
<FaDesktop size={24} />
<div className="text-lg font-bold truncate">Screen Sharing</div>
</div>
) : (
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100')}>
<div className="text-lg font-bold truncate">{props.songName}</div>
<div className="text-sm truncate">{props.fileName}</div>
</div>
);
return (
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col md:flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center gap-4">
<div className="flex-grow min-w-0 w-full md:w-auto text-white">
<div className="text-lg font-bold truncate text-center md:text-left">{props.songName}</div>
<div className="text-sm truncate text-center md:text-left">{props.fileName}</div>
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg p-5 gap-4">
<div className="flex-grow min-w-0 w-full text-white text-left">
{titleArea}
</div>
<div className="flex flex-row items-center gap-4 justify-center md:justify-end">
<div className="flex items-center gap-2 text-white">
<div className="flex flex-row items-center gap-4 w-full">
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
<FaVolumeUp size={20} />
<input
type="range"
@@ -41,21 +58,37 @@ const NowPlaying: React.FC<NowPlayingProps> = (props) => {
onMouseDown={() => props.onVolumeWillChange(props.volume)}
onMouseUp={() => props.onVolumeDidChange(props.volume)}
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
className="fancy-slider w-48 md:w-24 h-2"
className="fancy-slider h-2 w-full"
/>
</div>
<div className="flex-grow"></div>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
<FaStepBackward size={24} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
{props.isPlaying ? <FaPause size={24} /> : <FaPlay size={24} />}
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
</button>
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
<FaStepForward size={24} />
</button>
{props.isScreenSharingSupported && (
<button
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
onClick={props.onScreenShare}
title="Share your screen"
>
<FaDesktop size={24} />
</button>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,136 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { API } from '../api/player';
interface UseScreenShareResult {
isScreenSharing: boolean;
isScreenSharingSupported: boolean;
toggleScreenShare: () => Promise<void>;
stopScreenShare: () => void;
}
function getBestSupportedMimeType() {
// Ordered by preference (best first) - all of these include audio+video
const mimeTypes = [
'video/webm;codecs=vp9,opus', // Best quality, good compression
'video/webm;codecs=vp8,opus', // Good fallback, well supported
'video/webm;codecs=h264,opus', // Better compatibility with some systems
'video/mp4;codecs=h264,aac', // Good for Safari but may not be supported for MediaRecorder
'video/webm', // Generic fallback (browser will choose codecs)
'video/mp4' // Last resort
];
// Find the first supported mimetype
for (const type of mimeTypes) {
if (MediaRecorder.isTypeSupported(type)) {
console.log(`Using mime type: ${type}`);
return type;
}
}
// If none are supported, return null or a basic fallback
console.warn('No preferred mime types supported by this browser');
return 'video/webm'; // Most basic fallback
}
export const useScreenShare = (): UseScreenShareResult => {
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false);
const screenShareSocketRef = useRef<WebSocket | null>(null);
// Check if screen sharing is supported
useEffect(() => {
setIsScreenSharingSupported(
typeof navigator !== 'undefined' &&
navigator.mediaDevices !== undefined &&
typeof navigator.mediaDevices.getDisplayMedia === 'function'
);
}, []);
const stopScreenShare = useCallback(() => {
if (screenShareSocketRef.current) {
screenShareSocketRef.current.close();
screenShareSocketRef.current = null;
}
setIsScreenSharing(false);
}, []);
const startScreenShare = useCallback(async () => {
try {
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
let mimeType = getBestSupportedMimeType();
console.log('Using MIME type:', mimeType);
const mediaRecorder = new MediaRecorder(mediaStream, {
mimeType: mimeType,
videoBitsPerSecond: 2500000, // 2.5 Mbps
audioBitsPerSecond: 128000, // 128 kbps
});
// Connect to WebSocket
screenShareSocketRef.current = API.startScreenShare(mimeType);
// Set up WebSocket event handlers
screenShareSocketRef.current.onopen = () => {
console.log('Screen sharing WebSocket connected');
setIsScreenSharing(true);
mediaRecorder.start(100);
};
screenShareSocketRef.current.onclose = () => {
console.log('Screen sharing WebSocket closed');
setIsScreenSharing(false);
// Stop all tracks when WebSocket is closed
mediaStream.getTracks().forEach(track => track.stop());
};
screenShareSocketRef.current.onerror = (error) => {
console.error('Screen sharing WebSocket error:', error);
setIsScreenSharing(false);
// Stop all tracks on error
mediaStream.getTracks().forEach(track => track.stop());
};
// Send data over WebSocket when available
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0 && screenShareSocketRef.current && screenShareSocketRef.current.readyState === WebSocket.OPEN) {
screenShareSocketRef.current.send(event.data);
}
};
// Handle stream ending (user clicks "Stop sharing")
mediaStream.getVideoTracks()[0].onended = () => {
if (screenShareSocketRef.current) {
screenShareSocketRef.current.close();
screenShareSocketRef.current = null;
}
setIsScreenSharing(false);
};
} catch (error) {
console.error('Error starting screen share:', error);
setIsScreenSharing(false);
}
}, []);
const toggleScreenShare = useCallback(async () => {
if (screenShareSocketRef.current) {
stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
return {
isScreenSharing,
isScreenSharingSupported,
toggleScreenShare,
stopScreenShare
};
};