diff --git a/backend/src/MediaPlayer.ts b/backend/src/MediaPlayer.ts index 2a52799..4e5ba5c 100644 --- a/backend/src/MediaPlayer.ts +++ b/backend/src/MediaPlayer.ts @@ -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(event: UserEvent, func: () => Promise): Promise { diff --git a/backend/src/server.ts b/backend/src/server.ts index 358a9b2..85148f4 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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) => { 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) { diff --git a/frontend/src/api/player.tsx b/frontend/src/api/player.tsx index ad3c0a4..823393d 100644 --- a/frontend/src/api/player.tsx +++ b/frontend/src/api/player.tsx @@ -53,6 +53,7 @@ export enum ServerEvent { FavoritesUpdate = "favorites_update", MetadataUpdate = "metadata_update", MPDUpdate = "mpd_update", + ScreenShare = "screen_share", } export const API = { @@ -90,6 +91,10 @@ export const API = { async play(): Promise { await fetch('/api/play', { method: 'POST' }); }, + + async stop(): Promise { + await fetch('/api/stop', { method: 'POST' }); + }, async pause(): Promise { 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; } }; diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 3d87587..0daa46b 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -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 = ({ songs, isPlaying, au const App: React.FC = () => { const [isPlaying, setIsPlaying] = useState(false); + const [isIdle, setIsIdle] = useState(false); const [nowPlayingSong, setNowPlayingSong] = useState(null); const [nowPlayingFileName, setNowPlayingFileName] = useState(null); const [volume, setVolume] = useState(100); @@ -92,6 +94,13 @@ const App: React.FC = () => { const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [favoriteToRename, setFavoriteToRename] = useState(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(); @@ -296,18 +313,23 @@ const App: React.FC = () => { return (
- setVolumeSettingIsLocked(true)} onVolumeDidChange={() => setVolumeSettingIsLocked(false)} + isScreenSharingSupported={isScreenSharingSupported} /> diff --git a/frontend/src/components/NowPlaying.tsx b/frontend/src/components/NowPlaying.tsx index 65cc477..cc3ef82 100644 --- a/frontend/src/components/NowPlaying.tsx +++ b/frontend/src/components/NowPlaying.tsx @@ -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 { 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 { } const NowPlaying: React.FC = (props) => { + const titleArea = props.isScreenSharing ? ( +
+ +
Screen Sharing
+
+ ) : ( +
+
{props.songName}
+
{props.fileName}
+
+ ); + return (
-
-
-
{props.songName}
-
{props.fileName}
+
+
+ {titleArea}
-
-
+
+
= (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" />
+ +
+ + + + {props.isScreenSharingSupported && ( + + )}
diff --git a/frontend/src/hooks/useScreenShare.ts b/frontend/src/hooks/useScreenShare.ts new file mode 100644 index 0000000..f45106c --- /dev/null +++ b/frontend/src/hooks/useScreenShare.ts @@ -0,0 +1,136 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { API } from '../api/player'; + +interface UseScreenShareResult { + isScreenSharing: boolean; + isScreenSharingSupported: boolean; + toggleScreenShare: () => Promise; + 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(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 + }; +}; \ No newline at end of file