This commit is contained in:
jiegeaiai 2025-02-10 00:31:53 +08:00
parent 683335df09
commit 0ed1920a6c
39 changed files with 34885 additions and 0 deletions

14
.gitignore vendored
View File

@ -108,3 +108,17 @@ DerivedDataCache/*
*.out
*.app
.DS_Store
web/node_modules
web/dist
# local env files
web/.env.local
web/.env.*.local
# Log files
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/pnpm-debug.log*

5
web/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

30417
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
web/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"echarts": "^5.3.2",
"lib-flexible": "^0.3.2",
"vue": "^2.6.11"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.13",
"@vue/cli-plugin-eslint": "~4.5.13",
"@vue/cli-service": "~4.5.13",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"postcss-px2rem": "^0.3.0",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {
"no-unused-vars": "off",
"no-prototype-builtins": "off",
"no-undef": "off"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width: 32px  |  Height: 32px  |  Size: 4.2 KiB

18
web/public/index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<script type="text/javascript" src="./scripts/webRtcPlayer.js"></script>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@ -0,0 +1,533 @@
// Copyright Epic Games, Inc. All Rights Reserved.
// universal module definition - read https://www.davidbcalhoun.com/2014/what-is-amd-commonjs-and-umd/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(["./adapter"], factory);
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(require("./adapter"));
} else {
// Browser globals (root is window)
root.webRtcPlayer = factory(root.adapter);
}
}(this, function (adapter) {
function webRtcPlayer(parOptions) {
parOptions = typeof parOptions !== 'undefined' ? parOptions : {};
var self = this;
//**********************
//Config setup
//**********************
this.cfg = typeof parOptions.peerConnectionOptions !== 'undefined' ? parOptions.peerConnectionOptions : {};
this.cfg.sdpSemantics = 'unified-plan';
// this.cfg.rtcAudioJitterBufferMaxPackets = 10;
// this.cfg.rtcAudioJitterBufferFastAccelerate = true;
// this.cfg.rtcAudioJitterBufferMinDelayMs = 0;
// If this is true in Chrome 89+ SDP is sent that is incompatible with UE Pixel Streaming 4.26 and below.
// However 4.27 Pixel Streaming does not need this set to false as it supports `offerExtmapAllowMixed`.
// tdlr; uncomment this line for older versions of Pixel Streaming that need Chrome 89+.
this.cfg.offerExtmapAllowMixed = false;
//**********************
//Variables
//**********************
this.pcClient = null;
this.dcClient = null;
this.tnClient = null;
this.sdpConstraints = {
offerToReceiveAudio: 1, //Note: if you don't need audio you can get improved latency by turning this off.
offerToReceiveVideo: 1,
voiceActivityDetection: false
};
// See https://www.w3.org/TR/webrtc/#dom-rtcdatachannelinit for values (this is needed for Firefox to be consistent with Chrome.)
this.dataChannelOptions = {ordered: true};
// This is useful if the video/audio needs to autoplay (without user input) as browsers do not allow autoplay non-muted of sound sources without user interaction.
this.startVideoMuted = typeof parOptions.startVideoMuted !== 'undefined' ? parOptions.startVideoMuted : false;
this.autoPlayAudio = typeof parOptions.autoPlayAudio !== 'undefined' ? parOptions.autoPlayAudio : true;
// To enable mic in browser use SSL/localhost and have ?useMic in the query string.
const urlParams = new URLSearchParams(window.location.search);
this.useMic = urlParams.has('useMic');
if(!this.useMic)
{
console.log("Microphone access is not enabled. Pass ?useMic in the url to enable it.");
}
// When ?useMic check for SSL or localhost
let isLocalhostConnection = location.hostname === "localhost" || location.hostname === "127.0.0.1";
let isHttpsConnection = location.protocol === 'https:';
if(this.useMic && !isLocalhostConnection && !isHttpsConnection)
{
this.useMic = false;
console.error("Microphone access in the browser will not work if you are not on HTTPS or localhost. Disabling mic access.");
console.error("For testing you can enable HTTP microphone access Chrome by visiting chrome://flags/ and enabling 'unsafely-treat-insecure-origin-as-secure'");
}
// Latency tester
this.latencyTestTimings =
{
TestStartTimeMs: null,
UEReceiptTimeMs: null,
UEPreCaptureTimeMs: null,
UEPostCaptureTimeMs: null,
UEPreEncodeTimeMs: null,
UEPostEncodeTimeMs: null,
UETransmissionTimeMs: null,
BrowserReceiptTimeMs: null,
FrameDisplayDeltaTimeMs: null,
Reset: function()
{
this.TestStartTimeMs = null;
this.UEReceiptTimeMs = null;
this.UEPreCaptureTimeMs = null;
this.UEPostCaptureTimeMs = null;
this.UEPreEncodeTimeMs = null;
this.UEPostEncodeTimeMs = null;
this.UETransmissionTimeMs = null;
this.BrowserReceiptTimeMs = null;
this.FrameDisplayDeltaTimeMs = null;
},
SetUETimings: function(UETimings)
{
this.UEReceiptTimeMs = UETimings.ReceiptTimeMs;
this.UEPreCaptureTimeMs = UETimings.PreCaptureTimeMs;
this.UEPostCaptureTimeMs = UETimings.PostCaptureTimeMs;
this.UEPreEncodeTimeMs = UETimings.PreEncodeTimeMs;
this.UEPostEncodeTimeMs = UETimings.PostEncodeTimeMs;
this.UETransmissionTimeMs = UETimings.TransmissionTimeMs;
this.BrowserReceiptTimeMs = Date.now();
this.OnAllLatencyTimingsReady(this);
},
SetFrameDisplayDeltaTime: function(DeltaTimeMs)
{
if(this.FrameDisplayDeltaTimeMs == null)
{
this.FrameDisplayDeltaTimeMs = Math.round(DeltaTimeMs);
this.OnAllLatencyTimingsReady(this);
}
},
OnAllLatencyTimingsReady: function(Timings){}
}
//**********************
//Functions
//**********************
//Create Video element and expose that as a parameter
this.createWebRtcVideo = function() {
var video = document.createElement('video');
video.id = "streamingVideo";
video.playsInline = true;
video.disablepictureinpicture = true;
video.muted =true;// self.startVideoMuted;
video.addEventListener('loadedmetadata', function(e){
if(self.onVideoInitialised){
self.onVideoInitialised();
}
}, true);
// Check if request video frame callback is supported
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
// The API is supported!
const onVideoFrameReady = (now, metadata) => {
if(metadata.receiveTime && metadata.expectedDisplayTime)
{
const receiveToCompositeMs = metadata.presentationTime - metadata.receiveTime;
self.aggregatedStats.receiveToCompositeMs = receiveToCompositeMs;
}
// Re-register the callback to be notified about the next frame.
video.requestVideoFrameCallback(onVideoFrameReady);
};
// Initially register the callback to be notified about the first frame.
video.requestVideoFrameCallback(onVideoFrameReady);
}
return video;
}
this.video = this.createWebRtcVideo();
onsignalingstatechange = function(state) {
console.info('signaling state change:', state)
};
oniceconnectionstatechange = function(state) {
console.info('ice connection state change:', state)
};
onicegatheringstatechange = function(state) {
console.info('ice gathering state change:', state)
};
handleOnTrack = function(e) {
console.log('handleOnTrack', e.streams);
if (e.track)
{
console.log('Got track - ' + e.track.kind + ' id=' + e.track.id + ' readyState=' + e.track.readyState);
}
if(e.track.kind == "audio")
{
handleOnAudioTrack(e.streams[0]);
return;
}
else(e.track.kind == "video" && self.video.srcObject !== e.streams[0])
{
self.video.srcObject = e.streams[0];
console.log('Set video source from video track ontrack.');
return;
}
};
handleOnAudioTrack = function(audioMediaStream)
{
// do nothing the video has the same media stream as the audio track we have here (they are linked)
if(self.video.srcObject == audioMediaStream)
{
return;
}
// video element has some other media stream that is not associated with this audio track
else if(self.video.srcObject && self.video.srcObject !== audioMediaStream)
{
// create a new audio element
let audioElem = document.createElement("Audio");
audioElem.srcObject = audioMediaStream;
// there is no way to autoplay audio (even muted), so we defer audio until first click
if(!self.autoPlayAudio) {
let clickToPlayAudio = function() {
audioElem.play();
self.video.removeEventListener("click", clickToPlayAudio);
};
self.video.addEventListener("click", clickToPlayAudio);
}
// we assume the user has clicked somewhere on the page and autoplaying audio will work
else {
// audioElem.play();
}
console.log('Created new audio element to play seperate audio stream.');
}
}
setupDataChannel = function(pc, label, options) {
try {
let datachannel = pc.createDataChannel(label, options);
console.log(`Created datachannel (${label})`)
// Inform browser we would like binary data as an ArrayBuffer (FF chooses Blob by default!)
datachannel.binaryType = "arraybuffer";
datachannel.onopen = function (e) {
console.log(`data channel (${label}) connect`)
if(self.onDataChannelConnected){
self.onDataChannelConnected();
}
}
datachannel.onclose = function (e) {
console.log(`data channel (${label}) closed`)
}
datachannel.onmessage = function (e) {
//console.log(`Got message (${label})`, e.data)
if (self.onDataChannelMessage)
self.onDataChannelMessage(e.data);
}
return datachannel;
} catch (e) {
console.warn('No data channel', e);
return null;
}
}
onicecandidate = function (e) {
console.log('ICE candidate', e)
if (e.candidate && e.candidate.candidate) {
self.onWebRtcCandidate(e.candidate);
}
};
handleCreateOffer = function (pc) {
pc.createOffer(self.sdpConstraints).then(function (offer) {
// Munging is where we modifying the sdp string to set parameters that are not exposed to the browser's WebRTC API
mungeSDPOffer(offer);
// Set our munged SDP on the local peer connection so it is "set" and will be send across
pc.setLocalDescription(offer);
if (self.onWebRtcOffer) {
self.onWebRtcOffer(offer);
}
},
function () { console.warn("Couldn't create offer") });
}
mungeSDPOffer = function (offer) {
// turn off video-timing sdp sent from browser
//offer.sdp = offer.sdp.replace("http://www.webrtc.org/experiments/rtp-hdrext/playout-delay", "");
// this indicate we support stereo (Chrome needs this)
offer.sdp = offer.sdp.replace('useinbandfec=1', 'useinbandfec=1;stereo=1;sprop-maxcapturerate=48000');
}
setupPeerConnection = function (pc) {
if (pc.SetBitrate)
console.log("Hurray! there's RTCPeerConnection.SetBitrate function");
//Setup peerConnection events
pc.onsignalingstatechange = onsignalingstatechange;
pc.oniceconnectionstatechange = oniceconnectionstatechange;
pc.onicegatheringstatechange = onicegatheringstatechange;
pc.ontrack = handleOnTrack;
pc.onicecandidate = onicecandidate;
};
generateAggregatedStatsFunction = function(){
if(!self.aggregatedStats)
self.aggregatedStats = {};
return function(stats){
//console.log('Printing Stats');
let newStat = {};
stats.forEach(stat => {
// console.log(JSON.stringify(stat, undefined, 4));
if (stat.type == 'inbound-rtp'
&& !stat.isRemote
&& (stat.mediaType == 'video' || stat.id.toLowerCase().includes('video'))) {
newStat.timestamp = stat.timestamp;
newStat.bytesReceived = stat.bytesReceived;
newStat.framesDecoded = stat.framesDecoded;
newStat.packetsLost = stat.packetsLost;
newStat.bytesReceivedStart = self.aggregatedStats && self.aggregatedStats.bytesReceivedStart ? self.aggregatedStats.bytesReceivedStart : stat.bytesReceived;
newStat.framesDecodedStart = self.aggregatedStats && self.aggregatedStats.framesDecodedStart ? self.aggregatedStats.framesDecodedStart : stat.framesDecoded;
newStat.timestampStart = self.aggregatedStats && self.aggregatedStats.timestampStart ? self.aggregatedStats.timestampStart : stat.timestamp;
if(self.aggregatedStats && self.aggregatedStats.timestamp){
if(self.aggregatedStats.bytesReceived){
// bitrate = bits received since last time / number of ms since last time
//This is automatically in kbits (where k=1000) since time is in ms and stat we want is in seconds (so a '* 1000' then a '/ 1000' would negate each other)
newStat.bitrate = 8 * (newStat.bytesReceived - self.aggregatedStats.bytesReceived) / (newStat.timestamp - self.aggregatedStats.timestamp);
newStat.bitrate = Math.floor(newStat.bitrate);
newStat.lowBitrate = self.aggregatedStats.lowBitrate && self.aggregatedStats.lowBitrate < newStat.bitrate ? self.aggregatedStats.lowBitrate : newStat.bitrate
newStat.highBitrate = self.aggregatedStats.highBitrate && self.aggregatedStats.highBitrate > newStat.bitrate ? self.aggregatedStats.highBitrate : newStat.bitrate
}
if(self.aggregatedStats.bytesReceivedStart){
newStat.avgBitrate = 8 * (newStat.bytesReceived - self.aggregatedStats.bytesReceivedStart) / (newStat.timestamp - self.aggregatedStats.timestampStart);
newStat.avgBitrate = Math.floor(newStat.avgBitrate);
}
if(self.aggregatedStats.framesDecoded){
// framerate = frames decoded since last time / number of seconds since last time
newStat.framerate = (newStat.framesDecoded - self.aggregatedStats.framesDecoded) / ((newStat.timestamp - self.aggregatedStats.timestamp) / 1000);
newStat.framerate = Math.floor(newStat.framerate);
newStat.lowFramerate = self.aggregatedStats.lowFramerate && self.aggregatedStats.lowFramerate < newStat.framerate ? self.aggregatedStats.lowFramerate : newStat.framerate
newStat.highFramerate = self.aggregatedStats.highFramerate && self.aggregatedStats.highFramerate > newStat.framerate ? self.aggregatedStats.highFramerate : newStat.framerate
}
if(self.aggregatedStats.framesDecodedStart){
newStat.avgframerate = (newStat.framesDecoded - self.aggregatedStats.framesDecodedStart) / ((newStat.timestamp - self.aggregatedStats.timestampStart) / 1000);
newStat.avgframerate = Math.floor(newStat.avgframerate);
}
}
}
//Read video track stats
if(stat.type == 'track' && (stat.trackIdentifier == 'video_label' || stat.kind == 'video')) {
newStat.framesDropped = stat.framesDropped;
newStat.framesReceived = stat.framesReceived;
newStat.framesDroppedPercentage = stat.framesDropped / stat.framesReceived * 100;
newStat.frameHeight = stat.frameHeight;
newStat.frameWidth = stat.frameWidth;
newStat.frameHeightStart = self.aggregatedStats && self.aggregatedStats.frameHeightStart ? self.aggregatedStats.frameHeightStart : stat.frameHeight;
newStat.frameWidthStart = self.aggregatedStats && self.aggregatedStats.frameWidthStart ? self.aggregatedStats.frameWidthStart : stat.frameWidth;
}
if(stat.type =='candidate-pair' && stat.hasOwnProperty('currentRoundTripTime') && stat.currentRoundTripTime != 0){
newStat.currentRoundTripTime = stat.currentRoundTripTime;
}
});
if(self.aggregatedStats.receiveToCompositeMs)
{
newStat.receiveToCompositeMs = self.aggregatedStats.receiveToCompositeMs;
self.latencyTestTimings.SetFrameDisplayDeltaTime(self.aggregatedStats.receiveToCompositeMs);
}
self.aggregatedStats = newStat;
if(self.onAggregatedStats)
self.onAggregatedStats(newStat)
}
};
setupTracksToSendAsync = async function(pc){
// Setup a transceiver for getting UE video
pc.addTransceiver("video", { direction: "recvonly" });
// Setup a transceiver for sending mic audio to UE and receiving audio from UE
if(!self.useMic)
{
pc.addTransceiver("audio", { direction: "recvonly" });
}
else
{
let audioSendOptions = self.useMic ?
{
autoGainControl: false,
channelCount: 1,
echoCancellation: false,
latency: 0,
noiseSuppression: false,
sampleRate: 16000,
volume: 1.0
} : false;
// Note using mic on android chrome requires SSL or chrome://flags/ "unsafely-treat-insecure-origin-as-secure"
const stream = await navigator.mediaDevices.getUserMedia({video: false, audio: audioSendOptions});
if(stream)
{
for (const track of stream.getTracks()) {
if(track.kind && track.kind == "audio")
{
pc.addTransceiver(track, { direction: "sendrecv" });
}
}
}
else
{
pc.addTransceiver("audio", { direction: "recvonly" });
}
}
};
//**********************
//Public functions
//**********************
this.setVideoEnabled = function(enabled) {
self.video.srcObject.getTracks().forEach(track => track.enabled = enabled);
}
this.startLatencyTest = function(onTestStarted) {
// Can't start latency test without a video element
if(!self.video)
{
return;
}
self.latencyTestTimings.Reset();
self.latencyTestTimings.TestStartTimeMs = Date.now();
onTestStarted(self.latencyTestTimings.TestStartTimeMs);
}
//This is called when revceiving new ice candidates individually instead of part of the offer
//This is currently not used but would be called externally from this class
this.handleCandidateFromServer = function(iceCandidate) {
console.log("ICE candidate: ", iceCandidate);
let candidate = new RTCIceCandidate(iceCandidate);
self.pcClient.addIceCandidate(candidate).then(_=>{
console.log('ICE candidate successfully added');
});
};
//Called externaly to create an offer for the server
this.createOffer = function() {
if(self.pcClient){
console.log("Closing existing PeerConnection")
self.pcClient.close();
self.pcClient = null;
}
self.pcClient = new RTCPeerConnection(self.cfg);
setupTracksToSendAsync(self.pcClient).finally(function()
{
setupPeerConnection(self.pcClient);
self.dcClient = setupDataChannel(self.pcClient, 'cirrus', self.dataChannelOptions);
handleCreateOffer(self.pcClient);
});
};
//Called externaly when an answer is received from the server
this.receiveAnswer = function(answer) {
console.log('Received answer:');
console.log(answer);
var answerDesc = new RTCSessionDescription(answer);
self.pcClient.setRemoteDescription(answerDesc);
let receivers = self.pcClient.getReceivers();
for(let receiver of receivers)
{
receiver.playoutDelayHint = 0;
}
};
this.close = function(){
if(self.pcClient){
console.log("Closing existing peerClient")
self.pcClient.close();
self.pcClient = null;
}
if(self.aggregateStatsIntervalId)
clearInterval(self.aggregateStatsIntervalId);
}
//Sends data across the datachannel
this.send = function(data){
if(self.dcClient && self.dcClient.readyState == 'open'){
//console.log('Sending data on dataconnection', self.dcClient)
self.dcClient.send(data);
}
};
this.getStats = function(onStats){
if(self.pcClient && onStats){
self.pcClient.getStats(null).then((stats) => {
onStats(stats);
});
}
}
this.aggregateStats = function(checkInterval){
let calcAggregatedStats = generateAggregatedStatsFunction();
let printAggregatedStats = () => { self.getStats(calcAggregatedStats); }
self.aggregateStatsIntervalId = setInterval(printAggregatedStats, checkInterval);
}
};
return webRtcPlayer;
}));

View File

@ -0,0 +1,838 @@
// Copyright Epic Games, Inc. All Rights Reserved.
let webRtcPlayerObj = null;
let is_reconnection = false;
let connect_on_load = false;
let ws;
const WS_OPEN_STATE = 1;
// TODO: Remove this - workaround because of bug causing UE to crash when switching resolutions too quickly
let lastTimeResized = new Date().getTime();
let resizeTimeout;
let onDataChannelConnected;
let responseEventListeners = new Map();
let shouldShowPlayOverlay = true;
// A hidden input text box which is used only for focusing and opening the
// on-screen keyboard.
let hiddenInput = undefined;
let t0 = Date.now();
function log(str) {
console.log(`${Math.floor(Date.now() - t0)}: ` + str);
}
function setOverlay(htmlClass, htmlElement, onClickFunction) {
let videoPlayOverlay = document.getElementById('videoPlayOverlay');
if (!videoPlayOverlay) {
let playerDiv = document.getElementById('player');
videoPlayOverlay = document.createElement('div');
videoPlayOverlay.id = 'videoPlayOverlay';
playerDiv.appendChild(videoPlayOverlay);
}
// Remove existing html child elements so we can add the new one
while (videoPlayOverlay.lastChild) {
videoPlayOverlay.removeChild(videoPlayOverlay.lastChild);
}
if (htmlElement)
videoPlayOverlay.appendChild(htmlElement);
if (onClickFunction) {
videoPlayOverlay.addEventListener('click', function onOverlayClick(event) {
onClickFunction(event);
videoPlayOverlay.removeEventListener('click', onOverlayClick);
});
}
// Remove existing html classes so we can set the new one
let cl = videoPlayOverlay.classList;
for (let i = cl.length - 1; i >= 0; i--) {
cl.remove(cl[i]);
}
videoPlayOverlay.classList.add(htmlClass);
}
function showConnectOverlay() {
let startText = document.createElement('div');
startText.id = 'playButton';
startText.innerHTML = 'Click to start';
setOverlay('clickableState', startText, event => {
connect();
});
}
function playVideoStream() {
if (webRtcPlayerObj && webRtcPlayerObj.video) {
webRtcPlayerObj.video.play().catch(function(onRejectedReason){
console.error(onRejectedReason);
console.log("Browser does not support autoplaying video without interaction - to resolve this we are going to show the play button overlay.")
shouldShowPlayOverlay = false;
});
requestInitialSettings();
requestQualityControl();
hideOverlay();
} else {
console.error("Could not player video stream because webRtcPlayerObj.video was not valid.")
}
}
function updateAfkOverlayText() {
afk.overlay.innerHTML = '<center>No activity detected<br>Disconnecting in ' + afk.countdown + ' seconds<br>Click to continue<br></center>';
}
function hideOverlay() {
setOverlay('hiddenState');
}
function createWebRtcOffer() {
if (webRtcPlayerObj) {
console.log('Creating offer');
webRtcPlayerObj.createOffer();
} else {
console.log('WebRTC player not setup, cannot create offer');
}
}
function sendInputData(data) {
if (webRtcPlayerObj) {
webRtcPlayerObj.send(data);
}
}
function addResponseEventListener(name, listener) {
responseEventListeners.set(name, listener);
}
function removeResponseEventListener(name) {
responseEventListeners.remove(name);
}
// Must be kept in sync with PixelStreamingProtocol::EToPlayerMsg C++ enum.
const ToClientMessageType = {
QualityControlOwnership: 0,
Response: 1,
Command: 2,
FreezeFrame: 3,
UnfreezeFrame: 4,
VideoEncoderAvgQP: 5,
LatencyTest: 6,
InitialSettings: 7
};
let VideoEncoderQP = "N/A";
function setupWebRtcPlayer(htmlElement, config) {
webRtcPlayerObj = new webRtcPlayer(config);
htmlElement.appendChild(webRtcPlayerObj.video);
webRtcPlayerObj.onWebRtcOffer = function(offer) {
if (ws && ws.readyState === WS_OPEN_STATE) {
let offerStr = JSON.stringify(offer);
console.log(`-> SS: offer:\n${offerStr}`);
ws.send(offerStr);
}
};
webRtcPlayerObj.onWebRtcCandidate = function(candidate) {
if (ws && ws.readyState === WS_OPEN_STATE) {
console.log(`-> SS: iceCandidate\n${JSON.stringify(candidate, undefined, 4)}`);
ws.send(JSON.stringify({
type: 'iceCandidate',
candidate: candidate
}));
}
};
webRtcPlayerObj.onVideoInitialised = function() {
if (ws && ws.readyState === WS_OPEN_STATE) {
if (shouldShowPlayOverlay) {
shouldShowPlayOverlay = false;
resizePlayerStyle();
}
else {
resizePlayerStyle();
playVideoStream();
}
}
};
webRtcPlayerObj.onDataChannelConnected = function() {
if (ws && ws.readyState === WS_OPEN_STATE) {
if (webRtcPlayerObj.video && webRtcPlayerObj.video.srcObject && webRtcPlayerObj.onVideoInitialised) {
webRtcPlayerObj.onVideoInitialised();
}
}
};
registerInputs(webRtcPlayerObj.video);
createWebRtcOffer();
return webRtcPlayerObj.video;
}
function onWebRtcAnswer(webRTCData) {
webRtcPlayerObj.receiveAnswer(webRTCData);
webRtcPlayerObj.aggregateStats(1 * 1000 );
}
function onWebRtcIce(iceCandidate) {
if (webRtcPlayerObj)
webRtcPlayerObj.handleCandidateFromServer(iceCandidate);
}
let styleWidth;
let styleHeight;
let styleTop;
let styleLeft;
let styleCursor = 'default';
let styleAdditional;
const ControlSchemeType = {
// A mouse can lock inside the WebRTC player so the user can simply move the
// mouse to control the orientation of the camera. The user presses the
// Escape key to unlock the mouse.
LockedMouse: 0,
// A mouse can hover over the WebRTC player so the user needs to click and
// drag to control the orientation of the camera.
HoveringMouse: 1
};
let inputOptions = {
// The control scheme controls the behaviour of the mouse when it interacts
// with the WebRTC player.
controlScheme: ControlSchemeType.LockedMouse,
// Browser keys are those which are typically used by the browser UI. We
// usually want to suppress these to allow, for example, UE4 to show shader
// complexity with the F5 key without the web page refreshing.
suppressBrowserKeys: true,
// UE4 has a faketouches option which fakes a single finger touch when the
// user drags with their mouse. We may perform the reverse; a single finger
// touch may be converted into a mouse drag UE4 side. This allows a
// non-touch application to be controlled partially via a touch device.
fakeMouseWithTouches: false
};
function resizePlayerStyleToFillWindow(playerElement) {
let videoElement = playerElement.getElementsByTagName("VIDEO");
// Fill the player display in window, keeping picture's aspect ratio.
let windowAspectRatio = window.innerHeight / window.innerWidth;
let playerAspectRatio = playerElement.clientHeight / playerElement.clientWidth;
// We want to keep the video ratio correct for the video stream
let videoAspectRatio = videoElement.videoHeight / videoElement.videoWidth;
if (isNaN(videoAspectRatio)) {
//Video is not initialised yet so set playerElement to size of window
styleWidth = window.innerWidth;
styleHeight = window.innerHeight;
styleTop = 0;
styleLeft = 0;
playerElement.style = "top: " + styleTop + "px; left: " + styleLeft + "px; width: " + styleWidth + "px; height: " + styleHeight + "px; cursor: " + styleCursor + "; " + styleAdditional;
} else if (windowAspectRatio < playerAspectRatio) {
// Window height is the constraining factor so to keep aspect ratio change width appropriately
styleWidth = Math.floor(window.innerHeight / videoAspectRatio);
styleHeight = window.innerHeight;
styleTop = 0;
styleLeft = Math.floor((window.innerWidth - styleWidth) * 0.5);
//Video is now 100% of the playerElement, so set the playerElement style
playerElement.style = "top: " + styleTop + "px; left: " + styleLeft + "px; width: " + styleWidth + "px; height: " + styleHeight + "px; cursor: " + styleCursor + "; " + styleAdditional;
} else {
// Window width is the constraining factor so to keep aspect ratio change height appropriately
styleWidth = window.innerWidth;
styleHeight = Math.floor(window.innerWidth * videoAspectRatio);
styleTop = Math.floor((window.innerHeight - styleHeight) * 0.5);
styleLeft = 0;
//Video is now 100% of the playerElement, so set the playerElement style
playerElement.style = "top: " + styleTop + "px; left: " + styleLeft + "px; width: " + styleWidth + "px; height: " + styleHeight + "px; cursor: " + styleCursor + "; " + styleAdditional;
}
}
function resizePlayerStyleToActualSize(playerElement) {
let videoElement = playerElement.getElementsByTagName("VIDEO");
if (videoElement.length > 0) {
// Display image in its actual size
styleWidth = videoElement[0].videoWidth;
styleHeight = videoElement[0].videoHeight;
let Top = Math.floor((window.innerHeight - styleHeight) * 0.5);
let Left = Math.floor((window.innerWidth - styleWidth) * 0.5);
styleTop = (Top > 0) ? Top : 0;
styleLeft = (Left > 0) ? Left : 0;
//Video is now 100% of the playerElement, so set the playerElement style
playerElement.style = "top: " + styleTop + "px; left: " + styleLeft + "px; width: " + styleWidth + "px; height: " + styleHeight + "px; cursor: " + styleCursor + "; " + styleAdditional;
}
}
function resizePlayerStyle(event) {
let playerElement = document.getElementById('player');
if (!playerElement)
return;
resizePlayerStyleToFillWindow(playerElement);
//resizePlayerStyleToActualSize(playerElement);
setupMouseAndFreezeFrame(playerElement)
}
function setupMouseAndFreezeFrame(playerElement) {
// Calculating and normalizing positions depends on the width and height of
// the player.
playerElementClientRect = playerElement.getBoundingClientRect();
setupNormalizeAndQuantize();
}
// Must be kept in sync with PixelStreamingProtocol::EToUE4Msg C++ enum.
const MessageType = {
/**********************************************************************/
/*
* Control Messages. Range = 0..49.
*/
IFrameRequest: 0,
RequestQualityControl: 1,
MaxFpsRequest: 2,
AverageBitrateRequest: 3,
StartStreaming: 4,
StopStreaming: 5,
LatencyTest: 6,
RequestInitialSettings: 7,
/**********************************************************************/
/*
* Input Messages. Range = 50..89.
*/
// Generic Input Messages. Range = 50..59.
UIInteraction: 50,
Command: 51,
// Keyboard Input Message. Range = 60..69.
KeyDown: 60,
KeyUp: 61,
KeyPress: 62,
// Mouse Input Messages. Range = 70..79.
MouseEnter: 70,
MouseLeave: 71,
MouseDown: 72,
MouseUp: 73,
MouseMove: 74,
MouseWheel: 75,
// Touch Input Messages. Range = 80..89.
TouchStart: 80,
TouchEnd: 81,
TouchMove: 82,
// Gamepad Input Messages. Range = 90..99
GamepadButtonPressed: 90,
GamepadButtonReleased: 91,
GamepadAnalog: 92
/**************************************************************************/
};
function requestInitialSettings() {
sendInputData(new Uint8Array([MessageType.RequestInitialSettings]).buffer);
}
function requestQualityControl() {
sendInputData(new Uint8Array([MessageType.RequestQualityControl]).buffer);
}
let playerElementClientRect = undefined;
let normalizeAndQuantizeUnsigned = undefined;
let normalizeAndQuantizeSigned = undefined;
function setupNormalizeAndQuantize() {
let playerElement = document.getElementById('player');
let videoElement = playerElement.getElementsByTagName("video");
if (playerElement && videoElement.length > 0) {
let playerAspectRatio = playerElement.clientHeight / playerElement.clientWidth;
let videoAspectRatio = videoElement[0].videoHeight / videoElement[0].videoWidth;
if (playerAspectRatio > videoAspectRatio) {
let ratio = playerAspectRatio / videoAspectRatio;
// Unsigned.
normalizeAndQuantizeUnsigned = (x, y) => {
let normalizedX = x / playerElement.clientWidth;
let normalizedY = ratio * (y / playerElement.clientHeight - 0.5) + 0.5;
if (normalizedX < 0.0 || normalizedX > 1.0 || normalizedY < 0.0 || normalizedY > 1.0) {
return {
inRange: false,
x: 65535,
y: 65535
};
} else {
return {
inRange: true,
x: normalizedX * 65536,
y: normalizedY * 65536
};
}
};
unquantizeAndDenormalizeUnsigned = (x, y) => {
let normalizedX = x / 65536;
let normalizedY = (y / 65536 - 0.5) / ratio + 0.5;
return {
x: normalizedX * playerElement.clientWidth,
y: normalizedY * playerElement.clientHeight
};
};
// Signed.
normalizeAndQuantizeSigned = (x, y) => {
let normalizedX = x / (0.5 * playerElement.clientWidth);
let normalizedY = (ratio * y) / (0.5 * playerElement.clientHeight);
return {
x: normalizedX * 32767,
y: normalizedY * 32767
};
};
} else {
let ratio = videoAspectRatio / playerAspectRatio;
// Unsigned.
normalizeAndQuantizeUnsigned = (x, y) => {
let normalizedX = ratio * (x / playerElement.clientWidth - 0.5) + 0.5;
let normalizedY = y / playerElement.clientHeight;
if (normalizedX < 0.0 || normalizedX > 1.0 || normalizedY < 0.0 || normalizedY > 1.0) {
return {
inRange: false,
x: 65535,
y: 65535
};
} else {
return {
inRange: true,
x: normalizedX * 65536,
y: normalizedY * 65536
};
}
};
unquantizeAndDenormalizeUnsigned = (x, y) => {
let normalizedX = (x / 65536 - 0.5) / ratio + 0.5;
let normalizedY = y / 65536;
return {
x: normalizedX * playerElement.clientWidth,
y: normalizedY * playerElement.clientHeight
};
};
// Signed.
normalizeAndQuantizeSigned = (x, y) => {
let normalizedX = (ratio * x) / (0.5 * playerElement.clientWidth);
let normalizedY = y / (0.5 * playerElement.clientHeight);
return {
x: normalizedX * 32767,
y: normalizedY * 32767
};
};
}
}
}
function emitMouseMove(x, y, deltaX, deltaY) {
let coord = normalizeAndQuantizeUnsigned(x, y);
let delta = normalizeAndQuantizeSigned(deltaX, deltaY);
let Data = new DataView(new ArrayBuffer(9));
Data.setUint8(0, MessageType.MouseMove);
Data.setUint16(1, coord.x, true);
Data.setUint16(3, coord.y, true);
Data.setInt16(5, delta.x, true);
Data.setInt16(7, delta.y, true);
sendInputData(Data.buffer);
}
function emitMouseDown(button, x, y) {
let coord = normalizeAndQuantizeUnsigned(x, y);
let Data = new DataView(new ArrayBuffer(6));
Data.setUint8(0, MessageType.MouseDown);
Data.setUint8(1, button);
Data.setUint16(2, coord.x, true);
Data.setUint16(4, coord.y, true);
sendInputData(Data.buffer);
}
function emitMouseUp(button, x, y) {
let coord = normalizeAndQuantizeUnsigned(x, y);
let Data = new DataView(new ArrayBuffer(6));
Data.setUint8(0, MessageType.MouseUp);
Data.setUint8(1, button);
Data.setUint16(2, coord.x, true);
Data.setUint16(4, coord.y, true);
sendInputData(Data.buffer);
}
function emitMouseWheel(delta, x, y) {
let coord = normalizeAndQuantizeUnsigned(x, y);
let Data = new DataView(new ArrayBuffer(7));
Data.setUint8(0, MessageType.MouseWheel);
Data.setInt16(1, delta, true);
Data.setUint16(3, coord.x, true);
Data.setUint16(5, coord.y, true);
sendInputData(Data.buffer);
}
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
const MouseButton = {
MainButton: 0, // Left button.
AuxiliaryButton: 1, // Wheel button.
SecondaryButton: 2, // Right button.
FourthButton: 3, // Browser Back button.
FifthButton: 4 // Browser Forward button.
};
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
const MouseButtonsMask = {
PrimaryButton: 1, // Left button.
SecondaryButton: 2, // Right button.
AuxiliaryButton: 4, // Wheel button.
FourthButton: 8, // Browser Back button.
FifthButton: 16 // Browser Forward button.
};
// If the user has any mouse buttons pressed then release them.
function releaseMouseButtons(buttons, x, y) {
if (buttons & MouseButtonsMask.PrimaryButton) {
emitMouseUp(MouseButton.MainButton, x, y);
}
if (buttons & MouseButtonsMask.SecondaryButton) {
emitMouseUp(MouseButton.SecondaryButton, x, y);
}
if (buttons & MouseButtonsMask.AuxiliaryButton) {
emitMouseUp(MouseButton.AuxiliaryButton, x, y);
}
if (buttons & MouseButtonsMask.FourthButton) {
emitMouseUp(MouseButton.FourthButton, x, y);
}
if (buttons & MouseButtonsMask.FifthButton) {
emitMouseUp(MouseButton.FifthButton, x, y);
}
}
// If the user has any mouse buttons pressed then press them again.
function pressMouseButtons(buttons, x, y) {
if (buttons & MouseButtonsMask.PrimaryButton) {
emitMouseDown(MouseButton.MainButton, x, y);
}
if (buttons & MouseButtonsMask.SecondaryButton) {
emitMouseDown(MouseButton.SecondaryButton, x, y);
}
if (buttons & MouseButtonsMask.AuxiliaryButton) {
emitMouseDown(MouseButton.AuxiliaryButton, x, y);
}
if (buttons & MouseButtonsMask.FourthButton) {
emitMouseDown(MouseButton.FourthButton, x, y);
}
if (buttons & MouseButtonsMask.FifthButton) {
emitMouseDown(MouseButton.FifthButton, x, y);
}
}
function registerInputs(playerElement) {
if (!playerElement)
return;
registerMouseEnterAndLeaveEvents(playerElement);
}
function registerMouseEnterAndLeaveEvents(playerElement) {
playerElement.onmouseenter = function(e) {
let Data = new DataView(new ArrayBuffer(1));
Data.setUint8(0, MessageType.MouseEnter);
sendInputData(Data.buffer);
playerElement.pressMouseButtons(e);
};
playerElement.onmouseleave = function(e) {
let Data = new DataView(new ArrayBuffer(1));
Data.setUint8(0, MessageType.MouseLeave);
sendInputData(Data.buffer);
playerElement.releaseMouseButtons(e);
};
}
// A locked mouse works by the user clicking in the browser player and the
// cursor disappears and is locked. The user moves the cursor and the camera
// moves, for example. The user presses escape to free the mouse.
function registerLockedMouseEvents(playerElement) {
let x = playerElement.width / 2;
let y = playerElement.height / 2;
playerElement.requestPointerLock = playerElement.requestPointerLock || playerElement.mozRequestPointerLock;
document.exitPointerLock = document.exitPointerLock || document.mozExitPointerLock;
playerElement.onclick = function() {
playerElement.requestPointerLock();
};
// Respond to lock state change events
document.addEventListener('pointerlockchange', lockStateChange, false);
document.addEventListener('mozpointerlockchange', lockStateChange, false);
function lockStateChange() {
if (document.pointerLockElement === playerElement ||
document.mozPointerLockElement === playerElement) {
console.log('Pointer locked');
document.addEventListener("mousemove", updatePosition, false);
} else {
console.log('The pointer lock status is now unlocked');
document.removeEventListener("mousemove", updatePosition, false);
}
}
function updatePosition(e) {
x += e.movementX;
y += e.movementY;
if (x > styleWidth) {
x -= styleWidth;
}
if (y > styleHeight) {
y -= styleHeight;
}
if (x < 0) {
x = styleWidth + x;
}
if (y < 0) {
y = styleHeight - y;
}
emitMouseMove(x, y, e.movementX, e.movementY);
}
playerElement.onmousedown = function(e) {
emitMouseDown(e.button, x, y);
};
playerElement.onmouseup = function(e) {
emitMouseUp(e.button, x, y);
};
playerElement.onmousewheel = function(e) {
emitMouseWheel(e.wheelDelta, x, y);
};
playerElement.pressMouseButtons = function(e) {
pressMouseButtons(e.buttons, x, y);
};
playerElement.releaseMouseButtons = function(e) {
releaseMouseButtons(e.buttons, x, y);
};
}
// A hovering mouse works by the user clicking the mouse button when they want
// the cursor to have an effect over the video. Otherwise the cursor just
// passes over the browser.
function registerHoveringMouseEvents(playerElement) {
styleCursor = 'none'; // We will rely on UE4 client's software cursor.
//styleCursor = 'default'; // Showing cursor
playerElement.onmousemove = function(e) {
emitMouseMove(e.offsetX, e.offsetY, e.movementX, e.movementY);
e.preventDefault();
};
playerElement.onmousedown = function(e) {
emitMouseDown(e.button, e.offsetX, e.offsetY);
e.preventDefault();
};
playerElement.onmouseup = function(e) {
emitMouseUp(e.button, e.offsetX, e.offsetY);
e.preventDefault();
};
// When the context menu is shown then it is safest to release the button
// which was pressed when the event happened. This will guarantee we will
// get at least one mouse up corresponding to a mouse down event. Otherwise
// the mouse can get stuck.
// https://github.com/facebook/react/issues/5531
playerElement.oncontextmenu = function(e) {
emitMouseUp(e.button, e.offsetX, e.offsetY);
e.preventDefault();
};
if ('onmousewheel' in playerElement) {
playerElement.onmousewheel = function(e) {
emitMouseWheel(e.wheelDelta, e.offsetX, e.offsetY);
e.preventDefault();
};
} else {
playerElement.addEventListener('DOMMouseScroll', function(e) {
emitMouseWheel(e.detail * -120, e.offsetX, e.offsetY);
e.preventDefault();
}, false);
}
playerElement.pressMouseButtons = function(e) {
pressMouseButtons(e.buttons, e.offsetX, e.offsetY);
};
playerElement.releaseMouseButtons = function(e) {
releaseMouseButtons(e.buttons, e.offsetX, e.offsetY);
};
}
// Browser keys do not have a charCode so we only need to test keyCode.
function isKeyCodeBrowserKey(keyCode) {
// Function keys or tab key.
return keyCode >= 112 && keyCode <= 123 || keyCode === 9;
}
// Must be kept in sync with JavaScriptKeyCodeToFKey C++ array. The index of the
// entry in the array is the special key code given below.
const SpecialKeyCodes = {
BackSpace: 8,
Shift: 16,
Control: 17,
Alt: 18,
RightShift: 253,
RightControl: 254,
RightAlt: 255
};
// We want to be able to differentiate between left and right versions of some
// keys.
function getKeyCode(e) {
if (e.keyCode === SpecialKeyCodes.Shift && e.code === 'ShiftRight') return SpecialKeyCodes.RightShift;
else if (e.keyCode === SpecialKeyCodes.Control && e.code === 'ControlRight') return SpecialKeyCodes.RightControl;
else if (e.keyCode === SpecialKeyCodes.Alt && e.code === 'AltRight') return SpecialKeyCodes.RightAlt;
else return e.keyCode;
}
function registerKeyboardEvents() {
document.onkeydown = function(e) {
sendInputData(new Uint8Array([MessageType.KeyDown, getKeyCode(e), e.repeat]).buffer);
// Backspace is not considered a keypress in JavaScript but we need it
// to be so characters may be deleted in a UE4 text entry field.
if (e.keyCode === SpecialKeyCodes.BackSpace) {
document.onkeypress({
charCode: SpecialKeyCodes.BackSpace
});
}
if (inputOptions.suppressBrowserKeys && isKeyCodeBrowserKey(e.keyCode)) {
e.preventDefault();
}
};
document.onkeyup = function(e) {
sendInputData(new Uint8Array([MessageType.KeyUp, getKeyCode(e)]).buffer);
if (inputOptions.suppressBrowserKeys && isKeyCodeBrowserKey(e.keyCode)) {
e.preventDefault();
}
};
document.onkeypress = function(e) {
let data = new DataView(new ArrayBuffer(3));
data.setUint8(0, MessageType.KeyPress);
data.setUint16(1, e.charCode, true);
sendInputData(data.buffer);
};
}
function start() {
showConnectOverlay();
if (webRtcPlayerObj) {
webRtcPlayerObj.setVideoEnabled(true);
}
if (!connect_on_load || is_reconnection) {
shouldShowPlayOverlay = true;
resizePlayerStyle();
} else {
connect();
}
// connect();
setTimeout(connect, 2000);
}
function connect() {
"use strict";
window.WebSocket = window.WebSocket || window.MozWebSocket;
if (!window.WebSocket) {
alert('Your browser doesn\'t support WebSocket');
return;
}
ws = new WebSocket(window.location.href.replace('http://', 'ws://').replace('https://', 'wss://'));
ws.onmessage = function(event) {
console.log(`<- SS: ${event.data}`);
let msg = JSON.parse(event.data);
if (msg.type === 'config') {
onConfig(msg);
} else if (msg.type === 'answer') {
onWebRtcAnswer(msg);
} else if (msg.type === 'iceCandidate') {
onWebRtcIce(msg.candidate);
} else {
console.log(`invalid SS message type: ${msg.type}`);
}
};
ws.onerror = function(event) {
console.log(`WS error: ${JSON.stringify(event)}`);
};
ws.onclose = function(event) {
console.log(`WS closed: ${JSON.stringify(event.code)} - ${event.reason}`);
ws = undefined;
is_reconnection = true;
// destroy `webRtcPlayerObj` if any
let playerDiv = document.getElementById('player');
if (webRtcPlayerObj) {
playerDiv.removeChild(webRtcPlayerObj.video);
webRtcPlayerObj.close();
webRtcPlayerObj = undefined;
}
setTimeout(start, 4000);
};
}
// Config data received from WebRTC sender via the Cirrus web server
function onConfig(config) {
let playerDiv = document.getElementById('player');
let playerElement = setupWebRtcPlayer(playerDiv, config);
resizePlayerStyle();
switch (inputOptions.controlScheme) {
case ControlSchemeType.HoveringMouse:
registerHoveringMouseEvents(playerElement);
break;
case ControlSchemeType.LockedMouse:
registerLockedMouseEvents(playerElement);
break;
default:
console.log(`ERROR: Unknown control scheme ${inputOptions.controlScheme}`);
registerLockedMouseEvents(playerElement);
break;
}
}
function load() {
registerKeyboardEvents();
start();
}

52
web/src/App.vue Normal file
View File

@ -0,0 +1,52 @@
<template>
<div id="app">
<WebRTC />
<div id="ui">
<TopBar />
<LeftPane />
<RightPane />
</div>
</div>
</template>
<script>
import WebRTC from './components/WebRTC.vue'
import TopBar from './components/TopBar.vue'
import LeftPane from "./components/LeftPane.vue"
import RightPane from "./components/RightPane.vue"
export default {
name: 'App',
components: {
WebRTC,
TopBar,
LeftPane,
RightPane
},
mounted()
{
//this.webrtc.load();
}
}
</script>
<style>
html,body
{
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#app {
height: 100%;
}
#ui
{
position:absolute;
width:100%;
left:0;
top:0;
pointer-events: none;
}
</style>

Binary file not shown.

After

(image error) Size: 86 KiB

Binary file not shown.

After

(image error) Size: 10 KiB

Binary file not shown.

After

(image error) Size: 3.1 KiB

Binary file not shown.

After

(image error) Size: 12 KiB

Binary file not shown.

After

(image error) Size: 2.4 KiB

Binary file not shown.

After

(image error) Size: 10 KiB

Binary file not shown.

After

(image error) Size: 15 KiB

Binary file not shown.

After

(image error) Size: 14 KiB

Binary file not shown.

After

(image error) Size: 11 KiB

Binary file not shown.

After

(image error) Size: 12 KiB

Binary file not shown.

After

(image error) Size: 11 KiB

Binary file not shown.

After

(image error) Size: 30 KiB

Binary file not shown.

After

(image error) Size: 25 KiB

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

(image error) Size: 6.7 KiB

View File

@ -0,0 +1,81 @@
<template>
<div id="board_car">
<span class="total">感应数量{{ total }}/</span>
<div class="perviews">
<div class="perview" v-for="(item,index) in induction" :key="index">
<img :src="item.img"/>
<span class="name">{{item.name}}</span>
<span class="time">{{item.time}}</span>
<span class="point">{{item.point}}</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: "BoardCar",
data() {
return {
total: 2000,
induction:[
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"15:15",point:"东风小区南门"},
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"11:17",point:"东风小区南门"},
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"13:19",point:"东风小区南门"},
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"11:15",point:"东风小区南门"},
{img:require("../assets/images/car_perview0.png"),name:"京A023FD",time:"13:15",point:"东风小区南门"},
]
};
},
methods: {},
mounted() {},
};
</script>
<style>
#board_car {
width: 480px;
height: 289px;
background: url("../assets/images/right_pane_bd2_bg.png") no-repeat;
background-size: contain;
margin-top: 10px;
}
#board_car .total {
float: right;
color: #01d6d8;
font-size: 14px;
margin-top: 15px;
margin-right: 35px;
}
#board_car .perviews {
float: left;
padding-left: 8px;
padding-top: 14px;
width: 100%;
}
#board_car .perview {
float: left;
width: 100px;
height: 94px;
margin-left: 10px;
margin-top: 14px;
padding:1px;
border: #01d6d8 solid 1px;
font-size: 10px;
color:white;
}
#board_car .name {
float: left;
margin-left: 2px;
}
#board_car .time {
float: right;
margin-right: 2px;
}
#board_car .point {
float: left;
margin-left: 2px;
}
</style>

View File

@ -0,0 +1,133 @@
<template>
<div id="board_event">
<div class="pipeview" v-for="(item,index) in events" :key="index">
<span class="title">{{item.name}}</span>
<div class="pipe" ref="pipe" />
<span class="value0txt">{{item.value[0]}}</span>
<span class="value1txt">{{item.value[1]}}</span>
</div>
</div>
</template>
<script>
export default {
name: "BoardEvent",
data()
{
return {
events:[
{name:"东风小区",value:[10120,10120*0.5]},
{name:"西风小区",value:[20120,20120*0.85]},
{name:"北风小区",value:[30120,30120*0.75]},
{name:"南风小区",value:[3120,3120*0.5]},
],
};
},
methods: {
createPipe(el, data) {
let pipe = this.echarts.init(el);
let option = {
animation:true,
animationDuration:5000,
animationEasing:'bounceOut',
title: {
itemGap:0,
text: Math.floor(data.value[1]/(data.value[0])*100)+"%",
subtext: "处置率",
left: "center",
top: "center",
textStyle: {
fontSize: 15,
color: "#ffffff",
},
subtextStyle: {
fontSize: 10,
color: "#01e0e2",
},
},
tooltip: {
trigger: "item",
},
color: ["#0357b6", "#00e4f1"],
series: [
{
name: "Access From",
type: "pie",
radius: ["70%", "90%"],
startAngle:-180,
avoidLabelOverlap: true,
label: {
show: false,
},
data: [
{ value: data.value[0], name: "处置" },
{ value: data.value[1], name: "发生" },
],
},
],
};
pipe.setOption(option);
window.addEventListener("resize",()=>
{
pipe.resize();
pipe.clear(); pipe.setOption(option);
});
setTimeout(()=>{pipe.resize();pipe.clear(); pipe.setOption(option);},200);
},
},
mounted()
{
for(var i=0;i<this.events.length;i++)
{
this.createPipe(this.$refs.pipe[i],this.events[i]);
}
}
};
</script>
<style>
#board_event {
width: 484px;
height: 335px;
background: url("../assets/images/left_pane_bd1_bg.png") no-repeat;
background-size: contain;
margin-top: 10px;
padding-top: 48px;
padding-left: 6px;
font-size: 16px;
color: white;
}
#board_event .pipeview {
float: left;
width: 230px;
height: 131px;
background: url("../assets/images/left_pane_bd1_pipe_bg.png") no-repeat;
background-size: contain;
margin-top: 10px;
margin-left: 8px;
}
#board_event .pipe {
clear: left;
float: left;
width: 80px;
height: 80px;
margin-top: 15px;
margin-left: 35px;
}
#board_event .title {
float: left;
font-size: 14px;
margin-top: 10px;
margin-left: 33px;
}
#board_event .value0txt
{
float:left;
margin-left:40px;
margin-top:20px;
}
#board_event .value1txt
{
float:left;
margin-left:40px;
margin-top:35px;
}
</style>

View File

@ -0,0 +1,76 @@
<template>
<div id="board_event_rt">
<table>
<tr class="dark">
<th width="15%">时间</th>
<th width="35%">地点</th>
<th width="35%">事件</th>
<th width="15%">状态</th>
</tr>
<tr v-for="(item,index) in events" :key="index" :class="index%2==0?'light':'dark'">
<td width="15%" >{{item.time}}</td>
<td width="35%">{{item.place}}</td>
<td width="35%" >{{item.event}}</td>
<td width="15%">{{item.state}}</td>
</tr>
</table>
</div>
</template>
<script>
export default {
name:'BoardEventRT',
data()
{
return {
events:
[
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"},
{time:"13:00",place:"南风小区",event:"寻衅滋事",state:"已处置"}
]
}
}
}
</script>
<style>
#board_event_rt
{
width: 484px;
height: 354px;
background: url("../assets/images/left_pane_bd2_bg.png") no-repeat;
background-size: contain;
margin-top: -35px;
font-size: 16px;
color: white;
}
#board_event_rt table
{
float:left;
width:456px;
height:288px;
margin-top: 55px;
margin-left: 16px;
border:0;
padding:0;
}
#board_event_rt table tr
{
height:26px;
vertical-align: 0;
text-align: center;
}
.dark
{
background-color:rgba(13, 94, 210, 0.3);
}
.light
{
background-color: rgba(13, 94, 210, 0.1);
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<div id="board_gate">
<div class="line" ref="line" />
</div>
</template>
<script>
export default {
name: "BoardGate",
methods: {
createLine(el, data) {
let line = this.echarts.init(el);
let option = {
color: ["#31b9ff", "#ff6262", "#ffa200"],
tooltip: {
trigger: "axis",
axisPointer: {
type: "cross",
label: {
backgroundColor: "#6a7985",
},
},
},
legend: {
itemGap: 20,
itemWidth: 14,
itemHeight: 14,
data: [
{ icon: "rect", name: "通行人次" },
{ icon: "rect", name: "通行车辆" },
{ icon: "rect", name: "通行非机动车次" },
],
textStyle: {
color: "#ffffff",
},
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true,
},
xAxis: [
{
type: "category",
boundaryGap: false,
data: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
axisLabel: {
interval: 0,
verticalAlign: "middle",
margin: 12,
textStyle: {
color: "#FFFFFF",
fontSize: 8,
lineHeight: 20,
},
},
},
],
yAxis: [
{
type: "value",
splitLine: {
show: true,
lineStyle: {
color: "rgba(255,255,255,.2)",
},
},
axisLabel: {
verticalAlign: "top",
textStyle: {
color: "#FFFFFF",
fontSize: 8,
lineHeight: 10,
},
},
},
],
series: [
{
name: "通行人次",
type: "line",
stack: "Total",
smooth: true,
lineStyle: {
width: 2,
},
showSymbol: false,
areaStyle: {
opacity: 0.4,
color: new this.echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgb(49, 185, 255)",
},
{
offset: 1,
color: "rgb(40, 150, 207)",
},
]),
},
emphasis: {
focus: "series",
},
data: data[0],
},
{
name: "通行车辆",
type: "line",
stack: "Total",
smooth: true,
lineStyle: {
width: 2,
},
showSymbol: false,
areaStyle: {
opacity: 0.4,
color: new this.echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgb(255, 98, 98)",
},
{
offset: 1,
color: "rgb(151, 58, 58)",
},
]),
},
emphasis: {
focus: "series",
},
data: data[1],
},
{
name: "通行非机动车次",
type: "line",
stack: "Total",
smooth: true,
lineStyle: {
width: 2,
},
showSymbol: false,
areaStyle: {
opacity: 0.4,
color: new this.echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgb(255, 162, 0)",
},
{
offset: 1,
color: "rgb(200, 136, 0)",
},
]),
},
emphasis: {
focus: "series",
},
data: data[2],
},
],
};
line.setOption(option);
window.addEventListener("resize", () => {
line.resize();
line.clear();
line.setOption(option);
});
setTimeout(() => {
line.resize();
line.clear();
line.setOption(option);
}, 200);
},
},
mounted() {
console.log(this.echarts);
this.createLine(this.$refs.line, [
[140, 232, 101, 264, 90, 340, 250],
[120, 282, 111, 234, 220, 340, 310],
[320, 132, 201, 334, 190, 130, 220],
]);
},
};
</script>
<style>
#board_gate {
width: 480px;
height: 283px;
background: url("../assets/images/right_pane_bd0_bg.png") no-repeat;
background-size: contain;
}
#board_gate .line {
width: 420px;
height: 220px;
padding-top: 60px;
padding-left: 20px;
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<div id="board_peo">
<span class="total">感应数量{{ total }}/</span>
<div class="perviews">
<div class="perview" v-for="(item,index) in induction" :key="index">
<img :src="item.img"/>
<span class="name">{{item.name}}</span>
<span class="time">{{item.time}}</span>
<span class="point">{{item.point}}</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: "BoardPeo",
data() {
return {
total: 2000,
induction:[
{img:require("../assets/images/peo_perview0.png"),name:"王小二",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/peo_perview1.png"),name:"王小二",time:"15:15",point:"东风小区南门"},
{img:require("../assets/images/peo_perview0.png"),name:"王小二",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/peo_perview1.png"),name:"王小二",time:"11:17",point:"东风小区南门"},
{img:require("../assets/images/peo_perview0.png"),name:"王小二",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/peo_perview1.png"),name:"王小二",time:"13:19",point:"东风小区南门"},
{img:require("../assets/images/peo_perview0.png"),name:"王小二",time:"11:15",point:"东风小区南门"},
{img:require("../assets/images/peo_perview1.png"),name:"王小二",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/peo_perview0.png"),name:"王小二",time:"12:35",point:"东风小区南门"},
{img:require("../assets/images/peo_perview1.png"),name:"王小二",time:"13:15",point:"东风小区南门"},
{img:require("../assets/images/peo_perview0.png"),name:"王小二",time:"14:25",point:"东风小区南门"},
{img:require("../assets/images/peo_perview1.png"),name:"王小二",time:"16:18",point:"东风小区南门"},
]
};
},
methods: {},
mounted() {},
};
</script>
<style>
#board_peo {
width: 480px;
height: 370px;
background: url("../assets/images/right_pane_bd1_bg.png") no-repeat;
background-size: contain;
margin-top: 10px;
}
#board_peo .total {
float: right;
color: #01d6d8;
font-size: 14px;
margin-top: 15px;
margin-right: 35px;
}
#board_peo .perviews {
float: left;
padding-left: 3px;
padding-top: 14px;
width: 100%;
}
#board_peo .perview {
float: left;
width: 100px;
height: 94px;
margin-left: 12px;
margin-top: 6px;
padding:1px;
border: #01d6d8 solid 1px;
font-size: 10px;
color:white;
}
#board_peo .name {
float: left;
margin-left: 2px;
}
#board_peo .time {
float: right;
margin-right: 2px;
}
#board_peo .point {
float: left;
margin-left: 2px;
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<div id="board_pop">
<div class="pipe" ref="pipe" />
<div class="nums">
<div class="numtxt">
<!-- TODO 数字动画 -->
实有人数<span>32901</span>
</div>
<div class="numtxt">
常驻人口<span>27901</span>
</div>
<div class="numtxt">
流动人口<span>5000</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: "BoardPop",
methods: {
//TODO
initPipe() {
let pipe = this.echarts.init(this.$refs.pipe);
let option = {
tooltip: {
trigger: "item",
},
color:['#6b0edb','#ff7723','#0279ff'],
series: [
{
name: "Access From",
type: "pie",
radius: "90%",
selectedMode:'single',
label:{
position:'inner',
fontSize:14,
color:"#ffffff"
},
labelLine:
{
show:false
},
data: [
{ value: 1048, name: "实有人数"},
{ value: 735, name: "流动人口" },
{ value: 580, name: "常驻人口" }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
},
],
};
pipe.setOption(option);
window.addEventListener("resize",()=>
{
pipe.resize();
pipe.clear(); pipe.setOption(option);
});
setTimeout(()=>{pipe.resize();pipe.clear(); pipe.setOption(option);},200);
},
},
mounted() {
this.initPipe();
},
};
</script>
<style>
#board_pop {
width: 484px;
height: 250px;
background: url("../assets/images/left_pane_bd0_bg.png") no-repeat;
background-size: cover;
}
#board_pop .pipe {
float:left;
width: 186px;
height: 186px;
margin-top: 52px;
margin-left: 35px;
}
#board_pop .nums {
float:left;
width: 240px;
height: 186px;
margin-top: 52px;
margin-left: 10px;
}
#board_pop .numtxt {
width: 235px;
height:61px;
background: url("../assets/images/left_pane_bd0_right_bg.png") no-repeat;
background-size: cover;
font-size: 18px;
color: white;
text-align: center;
line-height: 61px;
}
#board_pop span
{
font-size: 36px;
font-weight: bold;
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<div id="left_pane">
<BoardPop/>
<BoardEvent/>
<BoardEventRT/>
</div>
</template>
<script>
import BoardPop from './BoardPop.vue'
import BoardEvent from './BoardEvent.vue'
import BoardEventRT from './BoardEventRT.vue'
export default {
name:'LeftPane',
components: {
BoardPop,
BoardEvent,
BoardEventRT
},
}
</script>
<style>
#left_pane
{
clear:both;
float:left;
height:100%;
padding-left: 19px;
pointer-events:auto;
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<div id="pixel-stream">
<video ref="videoElement" autoplay playsinline></video>
</div>
</template>
<script>
import SimplePeer from 'simple-peer';
export default {
name: 'PixelStream',
data() {
return {
peer: null,
};
},
mounted() {
this.setupPixelStream();
},
methods: {
setupPixelStream() {
// Example setup for WebRTC connection
this.peer = new SimplePeer({
initiator: true,
trickle: false,
});
this.peer.on('signal', data => {
// Send signal data to the server
console.log('Signal data:', data);
});
this.peer.on('stream', stream => {
// Attach the stream to the video element
this.$refs.videoElement.srcObject = stream;
});
// Example: Connect to the UE server
// You need to implement the signaling server logic
// this.connectToSignalingServer();
},
connectToSignalingServer() {
// Implement your signaling server connection logic here
},
},
};
</script>
<style scoped>
#pixel-stream {
width: 100%;
height: 100%;
background-color: black;
}
video {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div id="right_pane">
<BoardGate/>
<BoardPeo/>
<BoardCar/>
</div>
</template>
<script>
import BoardGate from './BoardGate.vue'
import BoardPeo from './BoardPeo.vue'
import BoardCar from './BoardCar.vue'
export default {
name:'RightPane',
components:
{
BoardGate,
BoardPeo,
BoardCar
}
}
</script>
<style>
#right_pane
{
float:right;
height:100%;
padding-right: 19px;
pointer-events: auto;
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div id="top_bar">
<div id="top_bar_left">
<span>XXXXXXX管理平台</span>
</div>
<div id="top_bar_right">
<span class="time">19:00</span>
<span class="date">2022年5月8日<br>星期天</span>
<span class="temp">10-15</span>
<span class="weather">晴转多云<br>微风2</span>
</div>
</div>
</template>
<script>
export default {
name:'TopBar',
}
</script>
<style>
#top_bar
{
width:100%;
color: white;
pointer-events: auto;
}
#top_bar_left
{
float:left;
width:723px;
height:100px;
background: url('../assets/images/top_bar_left_bg.png') no-repeat;
background-size: cover;
font-size:50px;
text-align:left;
font-weight: bold;
padding-left: 50px;
line-height:80px;
}
#top_bar_right
{
float:right;
width:914px;
height:70px;
background: url('../assets/images/top_bar_right_bg.png') no-repeat;
background-size: cover;
line-height: 70px;
}
#top_bar_right .time
{
float:left;
margin-left: 100px;
font-size: 35px;
font-weight: bold;
}
#top_bar_right .date
{
float:left;
margin-left: 10px;
font-size: 25px;
line-height: 30px;
padding-top:5px;
}
#top_bar_right .temp
{
float:left;
margin-left: 160px;
font-size: 35px;
font-weight: bold;
}
#top_bar_right .weather
{
float:left;
margin-left: 10px;
font-size: 25px;
line-height: 30px;
padding-top:5px;
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div id="web_rtc_view">
<div id="web_rtc_player">
</div>
</div>
</template>
<script>
import webrtc from '../webrtc';
export default
{
name:'WebRTC',
mounted()
{
webrtc.webrtc.load();
}
}
</script>
<style>
#web_rtc_view
{
width: 100%;
height: 100%;
background-color: #575f69;
}
</style>

12
web/src/main.js Normal file
View File

@ -0,0 +1,12 @@
import './utils/rem'
import Vue from 'vue'
import App from './App.vue'
import webrtc from './webrtc'
import * as echarts from 'echarts'
Vue.prototype.webrtc = webrtc.webrtc
Vue.prototype.echarts = echarts
Vue.config.productionTip = false
new Vue({
render: h => h(App),
}).$mount('#app')

18
web/src/utils/rem.js Normal file
View File

@ -0,0 +1,18 @@
(function(doc, win) {
var docEl = doc.documentElement;
var resizeEvt = 'orientationchange' in window ? 'orientationchange' : 'resize'
var recalc = function() {
var clientWidth = docEl.clientWidth
if (!clientWidth) return
var html = document.getElementsByTagName('html')[0]
var oWidth = document.body.clientWidth || document.documentElement.clientWidth
oWidth = oWidth>1920?1920:oWidth;
html.style.fontSize = oWidth / (1920/100) + 'px'
}
if (!doc.addEventListener) return
win.addEventListener(resizeEvt, recalc)
doc.addEventListener('DOMContentLoaded', recalc, false)
})(document, window)

1944
web/src/webrtc.js Normal file

File diff suppressed because it is too large Load Diff

18
web/vue.config.js Normal file
View File

@ -0,0 +1,18 @@
module.exports=
{
css:
{
loaderOptions:
{
css:{},
postcss:
{
plugins:[
require('postcss-px2rem')({
remUnit:100
})
]
}
}
}
}