Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 106 additions & 48 deletions src/components/widgets/camera/services/WebrtcCamerastreamerCamera.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ type RTCConfigurationWithSdpSemantics = RTCConfiguration & {
sdpSemantics: 'unified-plan'
}

const iceServers = [
{
urls: [
'stun:stun.l.google.com:19302'
]
}
]

@Component({})
export default class WebrtcCamerastreamerCamera extends Mixins(CameraMixin) {
@Ref('streamingElement')
Expand All @@ -31,6 +39,7 @@ export default class WebrtcCamerastreamerCamera extends Mixins(CameraMixin) {
remoteId: string | null = null
playbackAbortController: AbortController | null = null
sleepAbortController: AbortController | null = null
Comment thread
pedrolamas marked this conversation as resolved.
sendIceServers = true

// adapted from https://github.com/ayufan/camera-streamer/blob/2d3a4884378f384346680a55196bf9244b99b6b6/html/webrtc.html

Expand All @@ -50,26 +59,7 @@ export default class WebrtcCamerastreamerCamera extends Mixins(CameraMixin) {
this.updateRawCameraUrl(url.toString())

try {
const response = await fetch(url, {
body: JSON.stringify({
type: 'request',
iceServers: [
{
urls: [
'stun:stun.l.google.com:19302'
]
}
],
keepAlive: true
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
signal: abortControllerSignal
})

const rtcSessionDescriptionInit = await response.json() as RTCSessionDescriptionInit
const rtcSessionDescriptionInit = await this.sendInitialRequest(url, abortControllerSignal)

this.remoteId = ('id' in rtcSessionDescriptionInit && typeof rtcSessionDescriptionInit.id === 'string')
? rtcSessionDescriptionInit.id
Expand Down Expand Up @@ -109,18 +99,7 @@ export default class WebrtcCamerastreamerCamera extends Mixins(CameraMixin) {
pc.onicecandidate = async (event: RTCPeerConnectionIceEvent) => {
if (event.candidate) {
try {
await fetch(url, {
body: JSON.stringify({
type: 'remote_candidate',
id: this.remoteId,
candidates: [event.candidate]
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
signal: abortControllerSignal
})
await this.sendRemoteCandidatesRequest(url, [event.candidate], abortControllerSignal)
} catch (e) {
consola.error('[WebrtcCamerastreamerCamera] onicecandidate', e)
}
Expand All @@ -134,22 +113,9 @@ export default class WebrtcCamerastreamerCamera extends Mixins(CameraMixin) {

await pc.setLocalDescription(rtcLocalSessionDescriptionInit)

const offer = pc.localDescription

const response2 = await fetch(url, {
body: JSON.stringify({
type: offer?.type,
id: this.remoteId,
sdp: offer?.sdp
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
signal: abortControllerSignal
})

await response2.json()
if (pc.localDescription) {
await this.sendOfferRequest(url, pc.localDescription, abortControllerSignal)
}
} catch (e) {
consola.error(`[WebrtcCamerastreamerCamera] failed to start playback "${this.camera.name}"`, e)

Expand Down Expand Up @@ -190,6 +156,98 @@ export default class WebrtcCamerastreamerCamera extends Mixins(CameraMixin) {
await this.loadStream()
}

async sendInitialRequest (url: string | URL | Request, abortControllerSignal: AbortSignal): Promise<RTCSessionDescriptionInit> {
try {
const response = await fetch(url, {
body: JSON.stringify({
type: 'request',
...this.sendIceServers
? { iceServers }
: undefined,
Comment thread
pedrolamas marked this conversation as resolved.
keepAlive: true
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
signal: abortControllerSignal
})

await this.ensureResponseOk(response, 'application/json')

const data = await response.json() as RTCSessionDescriptionInit

return data
} catch (e) {
const aborted = (
abortControllerSignal.aborted ||
(
e instanceof Error &&
e.name === 'AbortError'
)
)

if (!aborted) {
// Switch whether to send iceServers next time
this.sendIceServers = !this.sendIceServers
}
Comment thread
pedrolamas marked this conversation as resolved.

throw e
}
}
Comment thread
pedrolamas marked this conversation as resolved.

async sendRemoteCandidatesRequest (url: string | URL | Request, candidates: RTCIceCandidateInit[], abortControllerSignal: AbortSignal): Promise<void> {
const response = await fetch(url, {
body: JSON.stringify({
type: 'remote_candidate',
id: this.remoteId,
candidates
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
signal: abortControllerSignal
})

await this.ensureResponseOk(response)
}
Comment on lines +213 to +214

Copilot AI Nov 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ensureResponseOk method consumes the response body by calling response.text() when validation fails. However, for successful responses in sendRemoteCandidatesRequest and sendOfferRequest, the response body is never consumed, which may cause resource leaks or prevent connection reuse in some browsers. Consider consuming the response in the success case as well, e.g., await response.text() after ensureResponseOk, or modify ensureResponseOk to always consume the body.

Copilot uses AI. Check for mistakes.

async sendOfferRequest (url: string | URL | Request, offer: RTCSessionDescriptionInit, abortControllerSignal: AbortSignal): Promise<void> {
const response = await fetch(url, {
body: JSON.stringify({
type: offer.type,
id: this.remoteId,
sdp: offer.sdp
}),
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
signal: abortControllerSignal
})

await this.ensureResponseOk(response)
}
Comment on lines +230 to +231

Copilot AI Nov 6, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ensureResponseOk method consumes the response body by calling response.text() when validation fails. However, for successful responses in sendRemoteCandidatesRequest and sendOfferRequest, the response body is never consumed, which may cause resource leaks or prevent connection reuse in some browsers. Consider consuming the response in the success case as well, e.g., await response.text() after ensureResponseOk, or modify ensureResponseOk to always consume the body.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fair point, but AFAIK we can indeed leave to the browser to collect unreferenced responses and dispose of them, so that should be fine to leave as it currently stands.


async ensureResponseOk (response: Response, expectedContentType?: string): Promise<void> {
const contentType = response.headers.get('Content-Type')

const responseOk = (
response.ok &&
(
!expectedContentType ||
contentType?.includes(expectedContentType)
)
)

if (!responseOk) {
const body = await response.text()

throw new Error(`Invalid response! Status: ${response.status}, Content-Type: ${contentType}, Body: ${body}`)
}
}

stopPlayback () {
this.updateStatus('disconnected')
this.playbackAbortController?.abort()
Expand Down