Files
fx_cast/ext/src/cast/senders/mirroring.ts

250 lines
7.8 KiB
TypeScript

import options from "../../lib/options";
import { Logger } from "../../lib/logger";
import type { ReceiverDevice } from "../../types";
import type { ReceiverAvailability } from "../sdk/enums";
import type Session from "../sdk/Session";
import cast, { ensureInit } from "../export";
const logger = new Logger("fx_cast [mirroring sender]");
const NS_FX_CAST = "urn:x-cast:fx_cast";
type MirroringAppMessage =
| { subject: "peerConnectionOffer"; data: RTCSessionDescriptionInit }
| { subject: "peerConnectionAnswer"; data: RTCSessionDescriptionInit }
| { subject: "iceCandidate"; data: RTCIceCandidateInit }
| { subject: "close" };
interface MirroringSenderOpts {
contextTabId?: number;
receiverDevice?: ReceiverDevice;
}
class MirroringSender {
private contextTabId?: number;
private receiverDevice?: ReceiverDevice;
private session?: Session;
private wasSessionRequested = false;
private peerConnection: Optional<RTCPeerConnection>;
// Stream opts
private streamMaxFrameRate = 1;
private streamMaxBitRate = 1;
private streamDownscaleFactor = 1;
private streamUseMaxResolution = false;
private streamMaxResolution: { width?: number; height?: number } = {};
constructor(opts: MirroringSenderOpts) {
this.contextTabId = opts.contextTabId;
this.receiverDevice = opts.receiverDevice;
this.init();
}
private async init() {
try {
await ensureInit({
contextTabId: this.contextTabId,
receiverDevice: this.receiverDevice
});
} catch (err) {
logger.error("Failed to initialize cast API", err);
}
const {
mirroringAppId,
mirroringStreamMaxFrameRate,
mirroringStreamMaxBitRate,
mirroringStreamDownscaleFactor,
mirroringStreamUseMaxResolution,
mirroringStreamMaxResolution
} = await options.getAll();
this.streamMaxFrameRate = mirroringStreamMaxFrameRate;
this.streamMaxBitRate = mirroringStreamMaxBitRate;
this.streamDownscaleFactor = mirroringStreamDownscaleFactor;
this.streamUseMaxResolution = mirroringStreamUseMaxResolution;
this.streamMaxResolution = mirroringStreamMaxResolution;
const sessionRequest = new cast.SessionRequest(mirroringAppId);
const apiConfig = new cast.ApiConfig(
sessionRequest,
this.sessionListener,
this.receiverListener
);
cast.initialize(apiConfig);
}
stop() {
this.peerConnection?.close();
this.session?.stop();
}
private sessionListener() {
// Unused
}
private receiverListener = (availability: ReceiverAvailability) => {
if (this.wasSessionRequested) return;
this.wasSessionRequested = true;
if (availability === cast.ReceiverAvailability.AVAILABLE) {
cast.requestSession(
session => {
this.session = session;
this.createMirroringConnection();
},
err => {
logger.error("Session request failed", err);
}
);
}
};
private sendMirroringAppMessage(message: MirroringAppMessage) {
if (!this.session) return;
this.session.sendMessage(NS_FX_CAST, message);
}
private async createMirroringConnection() {
const pc = new RTCPeerConnection();
this.peerConnection = pc;
this.session?.addMessageListener(NS_FX_CAST, async (_ns, message) => {
const parsedMessage = JSON.parse(message) as MirroringAppMessage;
switch (parsedMessage.subject) {
case "peerConnectionAnswer":
pc.setRemoteDescription(parsedMessage.data);
break;
case "iceCandidate":
pc.addIceCandidate(parsedMessage.data);
break;
}
});
pc.addEventListener("negotiationneeded", async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.sendMirroringAppMessage({
subject: "peerConnectionOffer",
data: offer
});
});
pc.addEventListener("icecandidate", ev => {
if (!ev.candidate) return;
this.sendMirroringAppMessage({
subject: "iceCandidate",
data: ev.candidate
});
});
// Connection listener
pc.addEventListener("iceconnectionstatechange", async () => {
if (pc.iceConnectionState !== "connected") {
return;
}
applyParameters();
});
/** Applies stream encoding parameters. */
const applyParameters = async () => {
// Set stream encoding parameters
const [sender] = pc.getSenders();
const params = sender.getParameters();
if (!params.encodings) {
params.encodings = [{}];
}
const [encoding] = params.encodings;
if (!(encoding as any).maxFramerate) {
(encoding as any).maxFramerate = this.streamMaxFrameRate;
}
if (!encoding.maxBitrate) {
encoding.maxBitrate = this.streamMaxBitRate;
}
encoding.scaleResolutionDownBy = this.streamDownscaleFactor;
// Handle limiting stream resolution
if (this.streamUseMaxResolution) {
const { width: trackWidth, height: trackHeight } =
sender.track?.getSettings() ?? {};
// Calculate downscale ratios for width/height
let widthRatio = 1;
let heightRatio = 1;
if (trackWidth && this.streamMaxResolution.width) {
widthRatio = trackWidth / this.streamMaxResolution.width;
}
if (trackHeight && this.streamMaxResolution.height) {
heightRatio = trackHeight / this.streamMaxResolution.height;
}
// Use the largest ratio to ensure below resolution limit
const downscaleRatio = Math.max(1, widthRatio, heightRatio);
// Multiply existing downscale
encoding.scaleResolutionDownBy *= downscaleRatio;
}
await sender.setParameters(params);
};
let stream: MediaStream;
try {
// Add screen media stream
stream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: "motion",
frameRate: this.streamMaxFrameRate
},
audio: false
});
const [track] = stream.getVideoTracks();
pc.addTrack(track, stream);
track.addEventListener("ended", () => this.stop());
} catch (err) {
logger.error("Failed to add display media stream!", err);
this.stop();
return;
}
/**
* Use a video element to get stream resize events and update
* scaling parameters.
*/
const video = document.createElement("video");
video.srcObject = stream;
video.addEventListener("resize", () => applyParameters());
video.play();
}
}
/**
* If loaded as a content script, opts are stored on the window object.
*/
if (window.location.protocol !== "moz-extension:") {
const window_ = window as any;
const sender = new MirroringSender({
contextTabId: window_.contextTabId,
receiverDevice: window_.receiverDevice
});
window.addEventListener("beforeunload", () => {
sender.stop();
});
}