Feature: adds screen sharing
This commit is contained in:
@@ -152,6 +152,40 @@ export class MediaPlayer {
|
|||||||
await this.loadFile(url, "replace");
|
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() {
|
public async play() {
|
||||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
|
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));
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadFile(url: string, mode: string) {
|
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
||||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode]));
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, options.join(',')]));
|
||||||
|
|
||||||
this.fetchMetadataAndNotify(url).catch(error => {
|
if (fetchMetadata) {
|
||||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
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> {
|
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||||
|
|||||||
@@ -19,9 +19,13 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import expressWs from "express-ws";
|
import expressWs from "express-ws";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
import { MediaPlayer } from "./MediaPlayer";
|
import { MediaPlayer } from "./MediaPlayer";
|
||||||
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||||
import { PlaylistItem } from './types';
|
import { PlaylistItem } from './types';
|
||||||
|
import { PassThrough } from "stream";
|
||||||
|
import { AddressInfo } from "net";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -30,6 +34,10 @@ expressWs(app);
|
|||||||
const apiRouter = express.Router();
|
const apiRouter = express.Router();
|
||||||
const mediaPlayer = new MediaPlayer();
|
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>) => {
|
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
||||||
return async (req: any, res: any) => {
|
return async (req: any, res: any) => {
|
||||||
try {
|
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) => {
|
apiRouter.get("/search", withErrorHandling(async (req, res) => {
|
||||||
const query = req.query.q as string;
|
const query = req.query.q as string;
|
||||||
if (!query) {
|
if (!query) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export enum ServerEvent {
|
|||||||
FavoritesUpdate = "favorites_update",
|
FavoritesUpdate = "favorites_update",
|
||||||
MetadataUpdate = "metadata_update",
|
MetadataUpdate = "metadata_update",
|
||||||
MPDUpdate = "mpd_update",
|
MPDUpdate = "mpd_update",
|
||||||
|
ScreenShare = "screen_share",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API = {
|
export const API = {
|
||||||
@@ -90,6 +91,10 @@ export const API = {
|
|||||||
async play(): Promise<void> {
|
async play(): Promise<void> {
|
||||||
await fetch('/api/play', { method: 'POST' });
|
await fetch('/api/play', { method: 'POST' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await fetch('/api/stop', { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await fetch('/api/pause', { method: 'POST' });
|
await fetch('/api/pause', { method: 'POST' });
|
||||||
@@ -168,5 +173,11 @@ export const API = {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ title }),
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
|||||||
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||||
import useWebSocket from 'react-use-websocket';
|
import useWebSocket from 'react-use-websocket';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { useScreenShare } from '../hooks/useScreenShare';
|
||||||
|
|
||||||
enum Tabs {
|
enum Tabs {
|
||||||
Playlist = "playlist",
|
Playlist = "playlist",
|
||||||
@@ -82,6 +83,7 @@ const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, au
|
|||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isIdle, setIsIdle] = useState(false);
|
||||||
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
|
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
|
||||||
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
|
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
|
||||||
const [volume, setVolume] = useState(100);
|
const [volume, setVolume] = useState(100);
|
||||||
@@ -92,6 +94,13 @@ const App: React.FC = () => {
|
|||||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||||
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isScreenSharing,
|
||||||
|
isScreenSharingSupported,
|
||||||
|
toggleScreenShare,
|
||||||
|
stopScreenShare
|
||||||
|
} = useScreenShare();
|
||||||
|
|
||||||
const fetchPlaylist = useCallback(async () => {
|
const fetchPlaylist = useCallback(async () => {
|
||||||
const playlist = await API.getPlaylist();
|
const playlist = await API.getPlaylist();
|
||||||
setPlaylist(playlist);
|
setPlaylist(playlist);
|
||||||
@@ -114,6 +123,7 @@ const App: React.FC = () => {
|
|||||||
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
||||||
setIsPlaying(!nowPlaying.isPaused);
|
setIsPlaying(!nowPlaying.isPaused);
|
||||||
setVolume(nowPlaying.volume);
|
setVolume(nowPlaying.volume);
|
||||||
|
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
||||||
}, [volumeSettingIsLocked]);
|
}, [volumeSettingIsLocked]);
|
||||||
|
|
||||||
const handleAddURL = async (url: string) => {
|
const handleAddURL = async (url: string) => {
|
||||||
@@ -143,6 +153,13 @@ const App: React.FC = () => {
|
|||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
stopScreenShare();
|
||||||
|
|
||||||
|
await API.stop();
|
||||||
|
fetchNowPlaying();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
await API.skip();
|
await API.skip();
|
||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
@@ -296,18 +313,23 @@ const App: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
||||||
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
|
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
|
||||||
<NowPlaying
|
<NowPlaying
|
||||||
className="flex flex-row md:rounded-t-2xl"
|
className="flex flex-row md:rounded-t-2xl"
|
||||||
songName={nowPlayingSong || "(Not Playing)"}
|
songName={nowPlayingSong || "(Not Playing)"}
|
||||||
fileName={nowPlayingFileName || ""}
|
fileName={nowPlayingFileName || ""}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
onPlayPause={togglePlayPause}
|
isIdle={isIdle}
|
||||||
onSkip={handleSkip}
|
onPlayPause={togglePlayPause}
|
||||||
onPrevious={handlePrevious}
|
onStop={handleStop}
|
||||||
|
onSkip={handleSkip}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
onScreenShare={toggleScreenShare}
|
||||||
|
isScreenSharing={isScreenSharing}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
onVolumeSettingChange={handleVolumeSettingChange}
|
onVolumeSettingChange={handleVolumeSettingChange}
|
||||||
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
||||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||||
|
isScreenSharingSupported={isScreenSharingSupported}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import React, { HTMLAttributes } from 'react';
|
import React, { HTMLAttributes } from 'react';
|
||||||
import classNames from 'classnames';
|
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> {
|
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
songName: string;
|
songName: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
isIdle: boolean;
|
||||||
volume: number;
|
volume: number;
|
||||||
onPlayPause: () => void;
|
onPlayPause: () => void;
|
||||||
|
onStop: () => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
|
|
||||||
|
onScreenShare: () => void;
|
||||||
|
isScreenSharingSupported: boolean;
|
||||||
|
isScreenSharing: boolean;
|
||||||
|
|
||||||
// Sent when the volume setting actually changes value
|
// Sent when the volume setting actually changes value
|
||||||
onVolumeSettingChange: (volume: number) => void;
|
onVolumeSettingChange: (volume: number) => void;
|
||||||
|
|
||||||
@@ -22,16 +28,27 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
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 (
|
return (
|
||||||
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
|
<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 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 flex-col w-full h-full bg-black/50 rounded-lg p-5 gap-4">
|
||||||
<div className="flex-grow min-w-0 w-full md:w-auto text-white">
|
<div className="flex-grow min-w-0 w-full text-white text-left">
|
||||||
<div className="text-lg font-bold truncate text-center md:text-left">{props.songName}</div>
|
{titleArea}
|
||||||
<div className="text-sm truncate text-center md:text-left">{props.fileName}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-4 justify-center md:justify-end">
|
<div className="flex flex-row items-center gap-4 w-full">
|
||||||
<div className="flex items-center gap-2 text-white">
|
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
|
||||||
<FaVolumeUp size={20} />
|
<FaVolumeUp size={20} />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -41,21 +58,37 @@ const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
|||||||
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
||||||
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
||||||
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
|
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>
|
||||||
|
|
||||||
|
<div className="flex-grow"></div>
|
||||||
|
|
||||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
|
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
|
||||||
<FaStepBackward size={24} />
|
<FaStepBackward size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
|
<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>
|
||||||
|
|
||||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
|
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
|
||||||
<FaStepForward size={24} />
|
<FaStepForward size={24} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
136
frontend/src/hooks/useScreenShare.ts
Normal file
136
frontend/src/hooks/useScreenShare.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user