Skip to content

Commit

Permalink
create VideoPlayerStatsForNerds.vue
Browse files Browse the repository at this point in the history
  • Loading branch information
Williangalvani authored and rafaellehmkuhl committed Oct 17, 2024
1 parent 23d889a commit a02192a
Show file tree
Hide file tree
Showing 2 changed files with 223 additions and 0 deletions.
213 changes: 213 additions & 0 deletions src/components/VideoPlayerStatsForNerds.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<template>
<div class="canvas-container">
<canvas ref="canvasRef" :width="width" :height="height"></canvas>
</div>
</template>

<script lang="ts" setup>
import { WebRTCStats } from '@peermetrics/webrtc-stats'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useVideoStore } from '@/stores/video'
import { WebRTCStatsEvent } from '@/types/video'
const videoStore = useVideoStore()
const props = defineProps({
width: {
type: Number,
default: 130,
},
height: {
type: Number,
default: 200,
},
updateInterval: {
type: Number,
default: 20,
},
streamName: {
type: String,
default: '',
},
})
const canvasRef = ref(null)
const framerateData = ref([])
const bitrateData = ref([])
let animationFrameId = null
let intervalId = null
let bitrate = 0
// cumulative values
let packetsLost = 0
let packetsReceived = 0
let totalProcessingDelay = 0
let nackCount = 0
let pliCount = 0
let firCount = 0
let framesReceived = 0
let connectionLost = false
let processingDelayDelta = 0
let freezes = 0
let frozenTime = 0
let framedrops = 0
let packetLossPercentage = 0
let framerate = 0
let videoHeight = 0
const maxDataPoints = 100
let maxBitrateReceived = 1000 // max bitrate received, used for scaling the plot
let maxFramerateReceived = 30 // max framerate received, used for scaling the plot
let absoluteMaxFrameRate = 120 // Absolute maximum framerate, used for dealing with outliers
const plotHeight = 60 // Height of the plot area
/**
* Normalize the value to fit the plot area
* @param {number} value - The current value
* @param {number} max - The maximum value currently in the plot
* @returns {number} The normalized value
*/
function normalizeValue(value: number, max: number): number {
return (value / max) * plotHeight
}
/**
* Draw the line plots and stats
*/
function draw(): void {
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
const { width, height } = props
ctx.clearRect(0, 0, width, height)
// Draw bitrate plot
ctx.strokeStyle = 'rgb(255, 165, 0)' // Orange
ctx.lineWidth = 1
ctx.beginPath()
for (let i = 0; i < bitrateData.value.length; i++) {
const x = (i / (maxDataPoints - 1)) * width
const y = height - normalizeValue(bitrateData.value[i], maxBitrateReceived)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
// Draw framerate plot
ctx.strokeStyle = 'rgb(0, 255, 0)' // Green
ctx.beginPath()
for (let i = 0; i < framerateData.value.length; i++) {
const x = (i / (maxDataPoints - 1)) * width
const y = height - normalizeValue(framerateData.value[i], maxFramerateReceived)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
// Print text stats
const color = connectionLost ? 'red' : 'white'
const stats = [
{ label: 'Stream', value: props.streamName, color: color },
{ label: 'Size', value: videoHeight ? `${videoHeight}p` : 'N/A', color: color },
{ label: 'Packets Lost', value: `${packetsLost} (${packetLossPercentage.toFixed(0)}%)`, color: color },
{ label: 'Frame drops', value: framedrops, color: color },
{ label: 'Nack', value: nackCount, color: color },
{ label: 'Pli', value: pliCount, color: color },
{ label: 'Fir', value: firCount, color: color },
{ label: 'Processing ', value: `${processingDelayDelta.toFixed(0)}ms`, color: color },
{ label: 'Freezes', value: `${freezes}(${frozenTime.toFixed(1)}s)`, color: color },
{ label: 'Bitrate', value: `${bitrate.toFixed(0)}kbps`, color: 'rgb(255, 165, 0)' },
{ label: 'FPS', value: framerate.toFixed(2), color: 'rgb(0, 255, 0)' },
]
ctx.font = '10px Arial'
stats.forEach((stat, index) => {
ctx.fillStyle = stat.color
ctx.fillText(`${stat.label}: ${stat.value}`, 5, 12 + index * 12)
})
animationFrameId = requestAnimationFrame(draw)
}
const webrtcStats = new WebRTCStats({ getStatsInterval: 100 })
/**
* Draws the lines and updates the stats
*/
function update(): void {
framerateData.value.push(framerate)
bitrateData.value.push(bitrate)
if (framerateData.value.length > maxDataPoints) framerateData.value.shift()
if (bitrateData.value.length > maxDataPoints) bitrateData.value.shift()
// Update max values
maxBitrateReceived = Math.max(maxBitrateReceived, ...bitrateData.value)
maxFramerateReceived = Math.max(maxFramerateReceived, ...framerateData.value)
if (maxFramerateReceived > absoluteMaxFrameRate) maxFramerateReceived = absoluteMaxFrameRate
}
watch(videoStore.activeStreams, (streams): void => {
Object.keys(streams).forEach((streamName) => {
if (streamName !== props.streamName) return
const session = streams[streamName]?.webRtcManager.session
if (!session || !session.peerConnection) return
if (webrtcStats.peersToMonitor[session.consumerId]) return
webrtcStats.addConnection({
pc: session.peerConnection,
peerId: session.consumerId,
connectionId: session.id,
remote: false,
})
})
})
onMounted(() => {
intervalId = setInterval(update, props.updateInterval)
draw()
webrtcStats.on('stats', (ev: WebRTCStatsEvent) => {
try {
const videoData = ev.data.video.inbound[0]
if (videoData === undefined) return
connectionLost = videoData.bitrate === 0
if (!isNaN(videoData.bitrate)) {
const newBitrate = videoData.bitrate / 1000
bitrate = bitrate * 0.8 + newBitrate * 0.2
}
packetsLost = videoData.packetsLost
nackCount = videoData.nackCount
pliCount = videoData.pliCount
firCount = videoData.firCount
packetsReceived = videoData.packetsReceived
let totalProcessingDelayDelta = videoData.totalProcessingDelay - totalProcessingDelay
let framesDelta = videoData.framesReceived - framesReceived
processingDelayDelta = (1000 * totalProcessingDelayDelta) / framesDelta
framesReceived = videoData.framesReceived
totalProcessingDelay = videoData.totalProcessingDelay
packetLossPercentage = (packetsLost / (packetsLost + packetsReceived)) * 100
freezes = videoData.freezeCount
frozenTime = videoData.totalFreezesDuration
framedrops = videoData.framesDropped
framerate = videoData.framesPerSecond ?? 0
videoHeight = videoData.frameHeight
} catch (e) {
console.error(e)
}
})
})
onUnmounted(() => {
clearInterval(intervalId)
cancelAnimationFrame(animationFrameId)
})
</script>

<style scoped>
.canvas-container {
position: absolute;
top: 50px;
left: 10px;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2;
}
</style>
10 changes: 10 additions & 0 deletions src/components/widgets/VideoPlayer.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<div ref="videoWidget" class="video-widget">
<statsForNerds v-if="widget.options.statsForNerds" :stream-name="externalStreamId" />
<div v-if="nameSelectedStream === undefined" class="no-video-alert">
<span>No video stream selected.</span>
</div>
Expand Down Expand Up @@ -88,6 +89,13 @@
:color="widget.options.flipVertically ? 'white' : undefined"
hide-details
/>
<v-switch
v-model="widget.options.statsForNerds"
class="my-1"
label="Stats for nerds"
:color="widget.options.statsForNerds ? 'white' : undefined"
hide-details
/>
<div class="flex-wrap justify-center d-flex ga-5">
<v-btn prepend-icon="mdi-file-rotate-left" variant="outlined" @click="rotateVideo(-90)"> Rotate Left</v-btn>
<v-btn prepend-icon="mdi-file-rotate-right" variant="outlined" @click="rotateVideo(+90)"> Rotate Right</v-btn>
Expand All @@ -101,6 +109,7 @@
import { storeToRefs } from 'pinia'
import { computed, onBeforeMount, onBeforeUnmount, ref, toRefs, watch } from 'vue'
import StatsForNerds from '@/components/VideoPlayerStatsForNerds.vue'
import { isEqual } from '@/libs/utils'
import { useAppInterfaceStore } from '@/stores/appInterface'
import { useVideoStore } from '@/stores/video'
Expand Down Expand Up @@ -134,6 +143,7 @@ onBeforeMount(() => {
flipHorizontally: false,
flipVertically: false,
rotationAngle: 0,
statsForNerds: false,
internalStreamName: undefined as string | undefined,
}
widget.value.options = Object.assign({}, defaultOptions, widget.value.options)
Expand Down

0 comments on commit a02192a

Please sign in to comment.