snapshot of https://github.com/pion/webrtc.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s
This commit is contained in:
70
examples/README.md
Normal file
70
examples/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
<h1 align="center">
|
||||
Examples
|
||||
</h1>
|
||||
|
||||
We've built an extensive collection of examples covering common use-cases. You can modify and extend these examples to get started quickly.
|
||||
|
||||
For more full featured examples that use 3rd party libraries see our **[example-webrtc-applications](https://github.com/pion/example-webrtc-applications)** repo.
|
||||
|
||||
### Overview
|
||||
#### Media API
|
||||
* [Reflect](reflect): The reflect example demonstrates how to have Pion send back to the user exactly what it receives using the same PeerConnection.
|
||||
* [Play from Disk](play-from-disk): The play-from-disk example demonstrates how to send video to your browser from a file saved to disk.
|
||||
* [Play from Disk Renegotiation](play-from-disk-renegotiation): The play-from-disk-renegotiation example is an extension of the play-from-disk example, but demonstrates how you can add/remove video tracks from an already negotiated PeerConnection.
|
||||
* [Insertable Streams](insertable-streams): The insertable-streams example demonstrates how Pion can be used to send E2E encrypted video and decrypt via insertable streams in the browser.
|
||||
* [Save to Disk](save-to-disk): The save-to-disk example shows how to record your webcam and save the footage to disk on the server side.
|
||||
* [Broadcast](broadcast): The broadcast example demonstrates how to broadcast a video to multiple peers. A broadcaster uploads the video once and the server forwards it to all other peers.
|
||||
* [RTP Forwarder](rtp-forwarder): The rtp-forwarder example demonstrates how to forward your audio/video streams using RTP.
|
||||
* [RTP to WebRTC](rtp-to-webrtc): The rtp-to-webrtc example demonstrates how to take RTP packets sent to a Pion process into your browser.
|
||||
* [Simulcast](simulcast): The simulcast example demonstrates how to accept and demux 1 Track that contains 3 Simulcast streams. It then returns the media as 3 independent Tracks back to the sender.
|
||||
* [Swap Tracks](swap-tracks): The swap-tracks example demonstrates deeper usage of the Pion Media API. The server accepts 3 media streams, and then dynamically routes them back as a single stream to the user.
|
||||
* [RTCP Processing](rtcp-processing) The rtcp-processing example demonstrates Pion's RTCP APIs. This allow access to media statistics and control information.
|
||||
* [Quick Switch](quick-switch) Use WebRTC to switch quickly between video feeds. Similiar to swap-tracks, but user controls when video is switched and uses static files.
|
||||
|
||||
#### Data Channel API
|
||||
* [Data Channels](data-channels): The data-channels example shows how you can send/recv DataChannel messages from a web browser.
|
||||
* [Data Channels Detach](data-channels-detach): The data-channels-detach example shows how you can send/recv DataChannel messages using the underlying DataChannel implementation directly. This provides a more idiomatic way of interacting with Data Channels.
|
||||
* [Data Channels Flow Control](data-channels-flow-control): Example data-channels-flow-control shows how to use the DataChannel API efficiently. You can measure the amount the rate at which the remote peer is receiving data, and structure your application accordingly.
|
||||
* [ORTC](ortc): Example ortc shows how you an use the ORTC API for DataChannel communication.
|
||||
* [Pion to Pion](pion-to-pion): Example pion-to-pion is an example of two pion instances communicating directly! It therefore has no corresponding web page.
|
||||
|
||||
#### Miscellaneous
|
||||
* [Custom Logger](custom-logger) The custom-logger demonstrates how the user can override the logging and process messages instead of printing to stdout. It has no corresponding web page.
|
||||
* [ICE Restart](ice-restart) Example ice-restart demonstrates how a WebRTC connection can roam between networks. This example restarts ICE in a loop and prints the new addresses it uses each time.
|
||||
* [ICE Single Port](ice-single-port) Example ice-single-port demonstrates how multiple WebRTC connections can be served from a single port. By default Pion listens on a new port for every PeerConnection. Pion can be configured to use a single port for multiple connections.
|
||||
* [ICE TCP](ice-tcp) Example ice-tcp demonstrates how a WebRTC connection can be made over TCP instead of UDP. By default Pion only does UDP. Pion can be configured to use a TCP port, and this TCP port can be used for many connections.
|
||||
* [ICE Proxy](ice-proxy) Example ice-proxy demonstrates how to use a proxy for TURN connections.
|
||||
* [Trickle ICE](trickle-ice) Example trickle-ice example demonstrates Pion WebRTC's Trickle ICE APIs. This is important to use since it allows ICE Gathering and Connecting to happen concurrently.
|
||||
* [VNet](vnet) Example vnet demonstrates Pion's network virtualisation library. This example connects two PeerConnections over a virtual network and prints statistics about the data traveling over it.
|
||||
|
||||
### Usage
|
||||
We've made it easy to run the browser based examples on your local machine.
|
||||
|
||||
1. Build and run the example server:
|
||||
``` sh
|
||||
git clone https://github.com/pion/webrtc.git webrtc
|
||||
cd pion/webrtc/examples
|
||||
go run examples.go
|
||||
```
|
||||
|
||||
2. Browse to [localhost](http://localhost) to browse through the examples. Note that you can change the port of the server using the ``--address`` flag:
|
||||
``` sh
|
||||
go run examples.go --address localhost:8080
|
||||
go run examples.go --address :8080 # listen on all available interfaces
|
||||
```
|
||||
|
||||
### WebAssembly
|
||||
Pion WebRTC can be used when compiled to WebAssembly, also known as WASM. In
|
||||
this case the library will act as a wrapper around the JavaScript WebRTC API.
|
||||
This allows you to use WebRTC from Go in both server and browser side code with
|
||||
little to no changes
|
||||
|
||||
Some of our examples have support for WebAssembly. The same examples server documented above can be used to run the WebAssembly examples. However, you have to compile them first. This is done as follows:
|
||||
|
||||
1. If the example supports WebAssembly it will contain a `main.go` file under the `jsfiddle` folder.
|
||||
2. Build this `main.go` file as follows:
|
||||
```
|
||||
GOOS=js GOARCH=wasm go build -o demo.wasm
|
||||
```
|
||||
3. Start the example server. Refer to the [usage](#usage) section for how you can build the example server.
|
||||
4. Browse to [localhost](http://localhost). The page should now give you the option to run the example using the WebAssembly binary.
|
||||
48
examples/bandwidth-estimation-from-disk/README.md
Normal file
48
examples/bandwidth-estimation-from-disk/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# bandwidth-estimation-from-disk
|
||||
bandwidth-estimation-from-disk demonstrates how to use Pion's Bandwidth Estimation APIs.
|
||||
|
||||
Pion provides multiple Bandwidth Estimators, but they all satisfy one interface. This interface
|
||||
emits an int for how much bandwidth is available to send. It is then up to the sender to meet that number.
|
||||
|
||||
## Instructions
|
||||
### Create IVF files named `high.ivf` `med.ivf` and `low.ivf`
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -g 30 -b:v .3M -s 320x240 low.ivf
|
||||
ffmpeg -i $INPUT_FILE -g 30 -b:v 1M -s 858x480 med.ivf
|
||||
ffmpeg -i $INPUT_FILE -g 30 -b:v 2.5M -s 1280x720 high.ivf
|
||||
```
|
||||
|
||||
### Download bandwidth-estimation-from-disk
|
||||
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/bandwidth-estimation-from-disk@latest
|
||||
```
|
||||
|
||||
### Open bandwidth-estimation-from-disk example page
|
||||
[jsfiddle.net](https://jsfiddle.net/a1cz42op/) you should see two text-areas, 'Start Session' button and 'Copy browser SessionDescription to clipboard'
|
||||
|
||||
### Run bandwidth-estimation-from-disk with your browsers Session Description as stdin
|
||||
The `output.ivf` you created should be in the same directory as `bandwidth-estimation-from-disk`. In the jsfiddle press 'Copy browser Session Description to clipboard' or copy the base64 string manually.
|
||||
|
||||
Now use this value you just copied as the input to `bandwidth-estimation-from-disk`
|
||||
|
||||
#### Linux/macOS
|
||||
Run `echo $BROWSER_SDP | bandwidth-estimation-from-disk`
|
||||
#### Windows
|
||||
1. Paste the SessionDescription into a file.
|
||||
1. Run `bandwidth-estimation-from-disk < my_file`
|
||||
|
||||
### Input bandwidth-estimation-from-disk's Session Description into your browser
|
||||
Copy the text that `bandwidth-estimation-from-disk` just emitted and copy into the second text area in the jsfiddle
|
||||
|
||||
### Hit 'Start Session' in jsfiddle, enjoy your video!
|
||||
A video should start playing in your browser above the input boxes. When `bandwidth-estimation-from-disk` switches quality levels it will print the old and new file like so.
|
||||
|
||||
```
|
||||
Switching from low.ivf to med.ivf
|
||||
Switching from med.ivf to high.ivf
|
||||
Switching from high.ivf to med.ivf
|
||||
```
|
||||
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
313
examples/bandwidth-estimation-from-disk/main.go
Normal file
313
examples/bandwidth-estimation-from-disk/main.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// bandwidth-estimation-from-disk demonstrates how to use Pion's Bandwidth Estimation APIs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/interceptor/pkg/cc"
|
||||
"github.com/pion/interceptor/pkg/gcc"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
)
|
||||
|
||||
const (
|
||||
lowFile = "low.ivf"
|
||||
lowBitrate = 300_000
|
||||
|
||||
medFile = "med.ivf"
|
||||
medBitrate = 1_000_000
|
||||
|
||||
highFile = "high.ivf"
|
||||
highBitrate = 2_500_000
|
||||
|
||||
ivfHeaderSize = 32
|
||||
)
|
||||
|
||||
func main() { //nolint:gocognit,cyclop,maintidx
|
||||
qualityLevels := []struct {
|
||||
fileName string
|
||||
bitrate int
|
||||
}{
|
||||
{lowFile, lowBitrate},
|
||||
{medFile, medBitrate},
|
||||
{highFile, highBitrate},
|
||||
}
|
||||
currentQuality := 0
|
||||
|
||||
for _, level := range qualityLevels {
|
||||
_, err := os.Stat(level.fileName)
|
||||
if os.IsNotExist(err) {
|
||||
panic(fmt.Sprintf("File %s was not found", level.fileName))
|
||||
}
|
||||
}
|
||||
|
||||
interceptorRegistry := &interceptor.Registry{}
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a Congestion Controller. This analyzes inbound and outbound data and provides
|
||||
// suggestions on how much we should be sending.
|
||||
//
|
||||
// Passing `nil` means we use the default Estimation Algorithm which is Google Congestion Control.
|
||||
// You can use the other ones that Pion provides, or write your own!
|
||||
congestionController, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) {
|
||||
return gcc.NewSendSideBWE(gcc.SendSideBWEInitialBitrate(lowBitrate))
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
estimatorChan := make(chan cc.BandwidthEstimator, 1)
|
||||
congestionController.OnNewPeerConnection(func(id string, estimator cc.BandwidthEstimator) { //nolint: revive
|
||||
estimatorChan <- estimator
|
||||
})
|
||||
|
||||
interceptorRegistry.Add(congestionController)
|
||||
if err = webrtc.ConfigureTWCCHeaderExtensionSender(mediaEngine, interceptorRegistry); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewAPI(
|
||||
webrtc.WithInterceptorRegistry(interceptorRegistry), webrtc.WithMediaEngine(mediaEngine),
|
||||
).NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait until our Bandwidth Estimator has been created
|
||||
estimator := <-estimatorChan
|
||||
|
||||
// Create a video track
|
||||
videoTrack, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "pion",
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rtpSender, err := peerConnection.AddTrack(videoTrack)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("Connection State has changed %s \n", connectionState.String())
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
})
|
||||
|
||||
// Wait for the offer to be pasted
|
||||
offer := webrtc.SessionDescription{}
|
||||
decode(readUntilNewline(), &offer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
if err = peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the answer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Open a IVF file and start reading using our IVFReader
|
||||
file, err := os.Open(qualityLevels[currentQuality].fileName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ivf, header, err := ivfreader.NewWith(file)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
|
||||
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
|
||||
//
|
||||
// It is important to use a time.Ticker instead of time.Sleep because
|
||||
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
|
||||
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
|
||||
ticker := time.NewTicker(
|
||||
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
|
||||
)
|
||||
defer ticker.Stop()
|
||||
frame := []byte{}
|
||||
frameHeader := &ivfreader.IVFFrameHeader{}
|
||||
currentTimestamp := uint64(0)
|
||||
|
||||
switchQualityLevel := func(newQualityLevel int) {
|
||||
fmt.Printf(
|
||||
"Switching from %s to %s \n",
|
||||
qualityLevels[currentQuality].fileName,
|
||||
qualityLevels[newQualityLevel].fileName,
|
||||
)
|
||||
|
||||
currentQuality = newQualityLevel
|
||||
ivf.ResetReader(setReaderFile(qualityLevels[currentQuality].fileName))
|
||||
for {
|
||||
if frame, frameHeader, err = ivf.ParseNextFrame(); err != nil {
|
||||
break
|
||||
} else if frameHeader.Timestamp >= currentTimestamp && frame[0]&0x1 == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ; true; <-ticker.C {
|
||||
targetBitrate := estimator.GetTargetBitrate()
|
||||
switch {
|
||||
// If current quality level is below target bitrate drop to level below
|
||||
case currentQuality != 0 && targetBitrate < qualityLevels[currentQuality].bitrate:
|
||||
switchQualityLevel(currentQuality - 1)
|
||||
|
||||
// If next quality level is above target bitrate move to next level
|
||||
case len(qualityLevels) > (currentQuality+1) && targetBitrate > qualityLevels[currentQuality+1].bitrate:
|
||||
switchQualityLevel(currentQuality + 1)
|
||||
|
||||
// Adjust outbound bandwidth for probing
|
||||
default:
|
||||
frame, frameHeader, err = ivf.ParseNextFrame()
|
||||
}
|
||||
|
||||
switch {
|
||||
// If we have reached the end of the file start again
|
||||
case errors.Is(err, io.EOF):
|
||||
ivf.ResetReader(setReaderFile(qualityLevels[currentQuality].fileName))
|
||||
|
||||
// No error write the video frame
|
||||
case err == nil:
|
||||
currentTimestamp = frameHeader.Timestamp
|
||||
if err = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Error besides io.EOF that we dont know how to handle
|
||||
default:
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setReaderFile(filename string) func(_ int64) io.Reader {
|
||||
return func(_ int64) io.Reader {
|
||||
file, err := os.Open(filename) // nolint
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err = file.Seek(ivfHeaderSize, io.SeekStart); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
40
examples/broadcast/README.md
Normal file
40
examples/broadcast/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# broadcast
|
||||
broadcast is a Pion WebRTC application that demonstrates how to broadcast a video to many peers, while only requiring the broadcaster to upload once.
|
||||
|
||||
This could serve as the building block to building conferencing software, and other applications where publishers are bandwidth constrained.
|
||||
|
||||
## Instructions
|
||||
### Download broadcast
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/broadcast@latest
|
||||
```
|
||||
|
||||
### Open broadcast example page
|
||||
[jsfiddle.net](https://jsfiddle.net/us4h58jx/) You should see two buttons `Publish a Broadcast` and `Join a Broadcast`
|
||||
|
||||
### Run Broadcast
|
||||
#### Linux/macOS
|
||||
Run `broadcast` OR run `main.go` in `github.com/pion/webrtc/examples/broadcast`
|
||||
|
||||
### Start a publisher
|
||||
|
||||
* Click `Publish a Broadcast`
|
||||
* Press `Copy browser SDP to clipboard` or copy the `Browser base64 Session Description` string manually
|
||||
* Run `curl localhost:8080 -d "$BROWSER_OFFER"`. `$BROWSER_OFFER` is the value you copied in the last step.
|
||||
* The `broadcast` terminal application will respond with an answer, paste this into the second input field in your browser.
|
||||
* Press `Start Session`
|
||||
* The connection state will be printed in the terminal and under `logs` in the browser.
|
||||
|
||||
### Join the broadcast
|
||||
* Click `Join a Broadcast`
|
||||
* Copy the string in the first input labelled `Browser base64 Session Description`
|
||||
* Run `curl localhost:8080 -d "$BROWSER_OFFER"`. `$BROWSER_OFFER` is the value you copied in the last step.
|
||||
* The `broadcast` terminal application will respond with an answer, paste this into the second input field in your browser.
|
||||
* Press `Start Session`
|
||||
* The connection state will be printed in the terminal and under `logs` in the browser.
|
||||
|
||||
You can change the listening port using `-port 8011`
|
||||
|
||||
You can `Join the broadcast` as many times as you want. The `broadcast` Golang application is relaying all traffic, so your browser only has to upload once.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
8
examples/broadcast/jsfiddle/demo.css
Normal file
8
examples/broadcast/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/broadcast/jsfiddle/demo.details
Normal file
8
examples/broadcast/jsfiddle/demo.details
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: broadcast
|
||||
description: Example of a broadcast using Pion WebRTC
|
||||
authors:
|
||||
- Sean DuBois
|
||||
30
examples/broadcast/jsfiddle/demo.html
Normal file
30
examples/broadcast/jsfiddle/demo.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<div id="signalingContainer" style="display: none">
|
||||
Browser base64 Session Description<br />
|
||||
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
|
||||
<button onclick="window.copySDP()">
|
||||
Copy browser SDP to clipboard
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
Golang base64 Session Description<br />
|
||||
<textarea id="remoteSessionDescription"></textarea> <br/>
|
||||
<button onclick="window.startSession()"> Start Session </button><br />
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
Video<br />
|
||||
<video id="video1" width="160" height="120" autoplay muted></video> <br />
|
||||
|
||||
<button class="createSessionButton" onclick="window.createSession(true)"> Publish a Broadcast </button>
|
||||
<button class="createSessionButton" onclick="window.createSession(false)"> Join a Broadcast </button><br />
|
||||
|
||||
<br />
|
||||
|
||||
Logs<br />
|
||||
<div id="logs"></div>
|
||||
82
examples/broadcast/jsfiddle/demo.js
Normal file
82
examples/broadcast/jsfiddle/demo.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const log = msg => {
|
||||
document.getElementById('logs').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
window.createSession = isPublisher => {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}
|
||||
]
|
||||
})
|
||||
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
|
||||
pc.onicecandidate = event => {
|
||||
if (event.candidate === null) {
|
||||
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
|
||||
}
|
||||
}
|
||||
|
||||
if (isPublisher) {
|
||||
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
||||
.then(stream => {
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream))
|
||||
document.getElementById('video1').srcObject = stream
|
||||
pc.createOffer()
|
||||
.then(d => pc.setLocalDescription(d))
|
||||
.catch(log)
|
||||
}).catch(log)
|
||||
} else {
|
||||
pc.addTransceiver('video')
|
||||
pc.createOffer()
|
||||
.then(d => pc.setLocalDescription(d))
|
||||
.catch(log)
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
const el = document.getElementById('video1')
|
||||
el.srcObject = event.streams[0]
|
||||
el.autoplay = true
|
||||
el.controls = true
|
||||
}
|
||||
}
|
||||
|
||||
window.startSession = () => {
|
||||
const sd = document.getElementById('remoteSessionDescription').value
|
||||
if (sd === '') {
|
||||
return alert('Session Description must not be empty')
|
||||
}
|
||||
|
||||
try {
|
||||
pc.setRemoteDescription(JSON.parse(atob(sd)))
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}
|
||||
|
||||
window.copySDP = () => {
|
||||
const browserSDP = document.getElementById('localSessionDescription')
|
||||
|
||||
browserSDP.focus()
|
||||
browserSDP.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
const msg = successful ? 'successful' : 'unsuccessful'
|
||||
log('Copying SDP was ' + msg)
|
||||
} catch (err) {
|
||||
log('Unable to copy SDP ' + err)
|
||||
}
|
||||
}
|
||||
|
||||
const btns = document.getElementsByClassName('createSessionButton')
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
btns[i].style = 'display: none'
|
||||
}
|
||||
|
||||
document.getElementById('signalingContainer').style = 'display: block'
|
||||
}
|
||||
242
examples/broadcast/main.go
Normal file
242
examples/broadcast/main.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// broadcast demonstrates how to broadcast a video to many peers, while only requiring the broadcaster to upload once.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/interceptor/pkg/intervalpli"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint:gocognit, cyclop
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "http server port")
|
||||
flag.Parse()
|
||||
|
||||
sdpChan := httpSDPServer(*port)
|
||||
|
||||
// Everything below is the Pion WebRTC API, thanks for using it ❤️.
|
||||
offer := webrtc.SessionDescription{}
|
||||
decode(<-sdpChan, &offer)
|
||||
fmt.Println("")
|
||||
|
||||
peerConnectionConfig := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a InterceptorRegistry. This is the user configurable RTP/RTCP Pipeline.
|
||||
// This provides NACKs, RTCP Reports and other features. If you use `webrtc.NewPeerConnection`
|
||||
// this is enabled by default. If you are manually managing You MUST create a InterceptorRegistry
|
||||
// for each PeerConnection.
|
||||
interceptorRegistry := &interceptor.Registry{}
|
||||
|
||||
// Use the default set of Interceptors
|
||||
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Register a intervalpli factory
|
||||
// This interceptor sends a PLI every 3 seconds. A PLI causes a video keyframe to be generated by the sender.
|
||||
// This makes our video seekable and more error resilent, but at a cost of lower picture quality and higher bitrates
|
||||
// A real world application should process incoming RTCP packets from viewers and forward them to senders
|
||||
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
interceptorRegistry.Add(intervalPliFactory)
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(mediaEngine),
|
||||
webrtc.WithInterceptorRegistry(interceptorRegistry),
|
||||
).NewPeerConnection(peerConnectionConfig)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Allow us to receive 1 video track
|
||||
if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
localTrackChan := make(chan *webrtc.TrackLocalStaticRTP)
|
||||
// Set a handler for when a new remote track starts, this just distributes all our packets
|
||||
// to connected peers
|
||||
peerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { //nolint: revive
|
||||
// Create a local track, all our SFU clients will be fed via this track
|
||||
localTrack, newTrackErr := webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "video", "pion")
|
||||
if newTrackErr != nil {
|
||||
panic(newTrackErr)
|
||||
}
|
||||
localTrackChan <- localTrack
|
||||
|
||||
rtpBuf := make([]byte, 1400)
|
||||
for {
|
||||
i, _, readErr := remoteTrack.Read(rtpBuf)
|
||||
if readErr != nil {
|
||||
panic(readErr)
|
||||
}
|
||||
|
||||
// ErrClosedPipe means we don't have any subscribers, this is ok if no peers have connected yet
|
||||
if _, err = localTrack.Write(rtpBuf[:i]); err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set the remote SessionDescription
|
||||
err = peerConnection.SetRemoteDescription(offer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Get the LocalDescription and take it to base64 so we can paste in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
localTrack := <-localTrackChan
|
||||
for {
|
||||
fmt.Println("")
|
||||
fmt.Println("Curl an base64 SDP to start sendonly peer connection")
|
||||
|
||||
recvOnlyOffer := webrtc.SessionDescription{}
|
||||
decode(<-sdpChan, &recvOnlyOffer)
|
||||
|
||||
// Create a new PeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfig)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rtpSender, err := peerConnection.AddTrack(localTrack)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the remote SessionDescription
|
||||
err = peerConnection.SetRemoteDescription(recvOnlyOffer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete = webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Get the LocalDescription and take it to base64 so we can paste in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
}
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// httpSDPServer starts a HTTP Server that consumes SDPs.
|
||||
func httpSDPServer(port int) chan string {
|
||||
sdpChan := make(chan string)
|
||||
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
fmt.Fprintf(res, "done") //nolint: errcheck
|
||||
sdpChan <- string(body)
|
||||
})
|
||||
|
||||
go func() {
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
|
||||
}()
|
||||
|
||||
return sdpChan
|
||||
}
|
||||
56
examples/custom-logger/README.md
Normal file
56
examples/custom-logger/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# custom-logger
|
||||
|
||||
`custom-logger` is an example demonstrating how to override the default logging behavior of the [Pion WebRTC](https://github.com/pion/webrtc) stack.
|
||||
By default, Pion logs everything to `stdout`.
|
||||
This example shows how to inject a **custom `LoggerFactory`** to handle logs from every subsystem (ICE, DTLS, SCTP, DataChannel...).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Creates a **custom logger** that implements `logging.LeveledLogger`.
|
||||
- Initializes two peer connections (`offerer` and `answerer`) locally.
|
||||
- Establishes a WebRTC connection between them.
|
||||
- Logs events from:
|
||||
- `ICE` candidate gathering
|
||||
- `DTLS` handshake
|
||||
- `SCTP` and `DataChannel` setup
|
||||
- Prints logs with clear prefixes like `customLogger Debug:`.
|
||||
|
||||
Ideal for:
|
||||
- Integrate with external monitoring systems
|
||||
- Store logs to files or databases
|
||||
- Debug complex WebRTC flows in a structured way
|
||||
|
||||
---
|
||||
|
||||
## How to run
|
||||
|
||||
### 1. Install the example
|
||||
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/custom-logger@latest
|
||||
```
|
||||
Make sure ```$(go env GOPATH)/bin ``` is in your ```PATH```.
|
||||
|
||||
You can add it to your PATH like this (zsh):
|
||||
|
||||
```
|
||||
echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
### 2.Run
|
||||
`custom-logger` or `go run main.go`
|
||||
|
||||
## Example output
|
||||
|
||||
```
|
||||
Creating logger for ice
|
||||
Creating logger for dtls
|
||||
Peer Connection State has changed: connected (answerer)
|
||||
Peer Connection State has changed: connected (offerer)
|
||||
customLogger Debug: Adding a new peer-reflexive candidate: 10.8.21.1:51196
|
||||
```
|
||||
|
||||
|
||||
You should see messages from our customLogger, as two PeerConnections start a session
|
||||
184
examples/custom-logger/main.go
Normal file
184
examples/custom-logger/main.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// custom-logger is an example of how the Pion API provides an customizable logging API
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// customLogger satisfies the interface logging.LeveledLogger
|
||||
// a logger is created per subsystem in Pion, so you can have custom
|
||||
// behavior per subsystem (ICE, DTLS, SCTP...)
|
||||
type customLogger struct{}
|
||||
|
||||
// Print all messages except trace.
|
||||
func (c customLogger) Trace(string) {}
|
||||
func (c customLogger) Tracef(string, ...any) {}
|
||||
|
||||
func (c customLogger) Debug(msg string) { fmt.Printf("customLogger Debug: %s\n", msg) }
|
||||
func (c customLogger) Debugf(format string, args ...any) {
|
||||
c.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
func (c customLogger) Info(msg string) { fmt.Printf("customLogger Info: %s\n", msg) }
|
||||
func (c customLogger) Infof(format string, args ...any) {
|
||||
c.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
func (c customLogger) Warn(msg string) { fmt.Printf("customLogger Warn: %s\n", msg) }
|
||||
func (c customLogger) Warnf(format string, args ...any) {
|
||||
c.Warn(fmt.Sprintf(format, args...))
|
||||
}
|
||||
func (c customLogger) Error(msg string) { fmt.Printf("customLogger Error: %s\n", msg) }
|
||||
func (c customLogger) Errorf(format string, args ...any) {
|
||||
c.Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// customLoggerFactory satisfies the interface logging.LoggerFactory
|
||||
// This allows us to create different loggers per subsystem. So we can
|
||||
// add custom behavior.
|
||||
type customLoggerFactory struct{}
|
||||
|
||||
func (c customLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger {
|
||||
fmt.Printf("Creating logger for %s \n", subsystem)
|
||||
|
||||
return customLogger{}
|
||||
}
|
||||
|
||||
// nolint: cyclop
|
||||
func main() {
|
||||
// Create a new API with a custom logger
|
||||
// This SettingEngine allows non-standard WebRTC behavior
|
||||
s := webrtc.SettingEngine{
|
||||
LoggerFactory: customLoggerFactory{},
|
||||
}
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
offerPeerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := offerPeerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close offerPeerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// We need a DataChannel so we can have ICE Candidates
|
||||
if _, err = offerPeerConnection.CreateDataChannel("custom-logger", nil); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
answerPeerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := answerPeerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close answerPeerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
offerPeerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s (offerer)\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
answerPeerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s (answerer)\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
|
||||
// send it to the other peer
|
||||
answerPeerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate != nil {
|
||||
if iceErr := offerPeerConnection.AddICECandidate(candidate.ToJSON()); iceErr != nil {
|
||||
panic(iceErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
|
||||
// send it to the other peer
|
||||
offerPeerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate != nil {
|
||||
if iceErr := answerPeerConnection.AddICECandidate(candidate.ToJSON()); iceErr != nil {
|
||||
panic(iceErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create an offer for the other PeerConnection
|
||||
offer, err := offerPeerConnection.CreateOffer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// SetLocalDescription, needed before remote gets offer
|
||||
if err = offerPeerConnection.SetLocalDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Take offer from remote, answerPeerConnection is now able to contact
|
||||
// the other PeerConnection
|
||||
if err = answerPeerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create an Answer to send back to our originating PeerConnection
|
||||
answer, err := answerPeerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set the answerer's LocalDescription
|
||||
if err = answerPeerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// SetRemoteDescription on original PeerConnection, this finishes our signaling
|
||||
// bother PeerConnections should be able to communicate with each other now
|
||||
if err = offerPeerConnection.SetRemoteDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
30
examples/data-channels-detach-create/README.md
Normal file
30
examples/data-channels-detach-create/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# data-channels-detach-create
|
||||
data-channels-detach is an example that shows how you can detach a data channel. This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel). This allows you to interact with the data channel using a more idiomatic API based on the `io.ReadWriteCloser` interface.
|
||||
|
||||
The example is meant to be used with data-channels-detach. This demonstrates two Go Pion processes communicating directly.
|
||||
|
||||
## Run data-channels-detach-create and make an offer to data-channels-detach via stdin
|
||||
```
|
||||
go run data-channels-detach-create/*.go | go run data-channels-detach/*.go
|
||||
```
|
||||
|
||||
## post the answer from data-channels-detach back to data-channels-detach-create
|
||||
You will see a base64 SDP printed to your console. You now need to communicate this back to `data-channels-detach-create` this can be done via a HTTP endpoint
|
||||
|
||||
`curl localhost:8080/sdp -d "BASE_64_SDP"`
|
||||
|
||||
## Output
|
||||
|
||||
On sucess you will get output like the following
|
||||
|
||||
```
|
||||
Peer Connection State has changed: connecting
|
||||
(Long base64 SDP that you should POST)
|
||||
Peer Connection State has changed: connected
|
||||
New DataChannel 1374394845054
|
||||
Data channel ''-'1374394845054' open.
|
||||
Message from DataChannel: kvmWkjYodyQcIlv
|
||||
Sending aMDnwlTfDYnfoUy
|
||||
Sending htqQtnbvygZKlmy
|
||||
Message from DataChannel: CMjZiNtsmIBpCaN
|
||||
```
|
||||
211
examples/data-channels-detach-create/main.go
Normal file
211
examples/data-channels-detach-create/main.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// data-channels-detach is an example that shows how you can detach a data channel.
|
||||
// This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel).
|
||||
// This allows you to interact with the data channel using a more idiomatic API based on
|
||||
// the `io.ReadWriteCloser` interface.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const messageSize = 15
|
||||
|
||||
func main() {
|
||||
sdpChan := httpSDPServer(8080)
|
||||
|
||||
// Since this behavior diverges from the WebRTC API it has to be
|
||||
// enabled using a settings engine. Mixing both detached and the
|
||||
// OnMessage DataChannel API is not supported.
|
||||
|
||||
// Create a SettingEngine and enable Detach
|
||||
s := webrtc.SettingEngine{}
|
||||
s.DetachDataChannels()
|
||||
|
||||
// Create an API object with the engine
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
|
||||
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection using the API object
|
||||
peerConnection, err := api.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
dataChannel, err := peerConnection.CreateDataChannel("", nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dataChannel.OnOpen(func() {
|
||||
fmt.Printf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID())
|
||||
|
||||
// Detach the data channel
|
||||
raw, dErr := dataChannel.Detach()
|
||||
if dErr != nil {
|
||||
panic(dErr)
|
||||
}
|
||||
|
||||
// Handle reading from the data channel
|
||||
go ReadLoop(raw)
|
||||
|
||||
// Handle writing to the data channel
|
||||
go WriteLoop(raw)
|
||||
})
|
||||
|
||||
// Create an offer to send to the browser
|
||||
offer, err := peerConnection.CreateOffer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
if err = peerConnection.SetLocalDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the offer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Wait for the answer to be submitted via HTTP
|
||||
answer := webrtc.SessionDescription{}
|
||||
decode(<-sdpChan, &answer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
err = peerConnection.SetRemoteDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// ReadLoop shows how to read from the datachannel directly.
|
||||
func ReadLoop(d io.Reader) {
|
||||
for {
|
||||
buffer := make([]byte, messageSize)
|
||||
n, err := d.Read(buffer)
|
||||
if err != nil {
|
||||
fmt.Println("Datachannel closed; Exit the readloop:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Message from DataChannel: %s\n", string(buffer[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
// WriteLoop shows how to write to the datachannel directly.
|
||||
func WriteLoop(d io.Writer) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
message, err := randutil.GenerateCryptoRandomString(
|
||||
messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Sending %s \n", message)
|
||||
if _, err := d.Write([]byte(message)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// httpSDPServer starts a HTTP Server that consumes SDPs.
|
||||
func httpSDPServer(port int) chan string {
|
||||
sdpChan := make(chan string)
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
fmt.Fprintf(w, "done") //nolint: errcheck
|
||||
sdpChan <- string(body)
|
||||
})
|
||||
|
||||
go func() {
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
|
||||
}()
|
||||
|
||||
return sdpChan
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
12
examples/data-channels-detach/README.md
Normal file
12
examples/data-channels-detach/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# data-channels-detach
|
||||
data-channels-detach is an example that shows how you can detach a data channel. This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel). This allows you to interact with the data channel using a more idiomatic API based on the `io.ReadWriteCloser` interface.
|
||||
|
||||
The example mirrors the data-channels example.
|
||||
|
||||
## Install
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/data-channels-detach@latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
The example can be used in the same way as the data-channel example or can be paired with the data-channels-detach-create example. In the latter case; run both example and exchange the offer/answer text by copy-pasting them on the other terminal.
|
||||
8
examples/data-channels-detach/jsfiddle/demo.css
Normal file
8
examples/data-channels-detach/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
20
examples/data-channels-detach/jsfiddle/demo.html
Normal file
20
examples/data-channels-detach/jsfiddle/demo.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
Browser base64 Session Description<br />
|
||||
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
|
||||
|
||||
Golang base64 Session Description<br />
|
||||
<textarea id="remoteSessionDescription"></textarea><br/>
|
||||
<button onclick="window.startSession()">Start Session</button><br />
|
||||
|
||||
<br />
|
||||
|
||||
<!--Message<br />
|
||||
<textarea id="message">This is my DataChannel message!</textarea> <br/>
|
||||
<button onclick="window.sendMessage()">Send Message</button> <br />-->
|
||||
|
||||
<br />
|
||||
Logs<br />
|
||||
<div id="logs"></div>
|
||||
231
examples/data-channels-detach/jsfiddle/main.go
Normal file
231
examples/data-channels-detach/jsfiddle/main.go
Normal file
@@ -0,0 +1,231 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const messageSize = 15
|
||||
|
||||
func main() {
|
||||
// Since this behavior diverges from the WebRTC API it has to be
|
||||
// enabled using a settings engine. Mixing both detached and the
|
||||
// OnMessage DataChannel API is not supported.
|
||||
|
||||
// Create a SettingEngine and enable Detach
|
||||
s := webrtc.SettingEngine{}
|
||||
s.DetachDataChannels()
|
||||
|
||||
// Create an API object with the engine
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
|
||||
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection using the API object
|
||||
peerConnection, err := api.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
// Create a datachannel with label 'data'
|
||||
dataChannel, err := peerConnection.CreateDataChannel("data", nil)
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
log(fmt.Sprintf("ICE Connection State has changed: %s\n", connectionState.String()))
|
||||
})
|
||||
|
||||
// Register channel opening handling
|
||||
dataChannel.OnOpen(func() {
|
||||
log(fmt.Sprintf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID()))
|
||||
|
||||
// Detach the data channel
|
||||
raw, dErr := dataChannel.Detach()
|
||||
if dErr != nil {
|
||||
handleError(dErr)
|
||||
}
|
||||
|
||||
// Handle reading from the data channel
|
||||
go ReadLoop(raw)
|
||||
|
||||
// Handle writing to the data channel
|
||||
go WriteLoop(raw)
|
||||
})
|
||||
|
||||
// Create an offer to send to the browser
|
||||
offer, err := peerConnection.CreateOffer(nil)
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(offer)
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
// Add handlers for setting up the connection.
|
||||
peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
log(fmt.Sprint(state))
|
||||
})
|
||||
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate != nil {
|
||||
encodedDescr := encode(peerConnection.LocalDescription())
|
||||
el := getElementByID("localSessionDescription")
|
||||
el.Set("value", encodedDescr)
|
||||
}
|
||||
})
|
||||
|
||||
// Set up global callbacks which will be triggered on button clicks.
|
||||
/*js.Global().Set("sendMessage", js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
go func() {
|
||||
el := getElementByID("message")
|
||||
message := el.Get("value").String()
|
||||
if message == "" {
|
||||
js.Global().Call("alert", "Message must not be empty")
|
||||
return
|
||||
}
|
||||
if err := sendChannel.SendText(message); err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
}()
|
||||
return js.Undefined()
|
||||
}))*/
|
||||
js.Global().Set("startSession", js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
go func() {
|
||||
el := getElementByID("remoteSessionDescription")
|
||||
sd := el.Get("value").String()
|
||||
if sd == "" {
|
||||
js.Global().Call("alert", "Session Description must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
descr := webrtc.SessionDescription{}
|
||||
decode(sd, &descr)
|
||||
if err := peerConnection.SetRemoteDescription(descr); err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
}()
|
||||
return js.Undefined()
|
||||
}))
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// ReadLoop shows how to read from the datachannel directly
|
||||
func ReadLoop(d io.Reader) {
|
||||
for {
|
||||
buffer := make([]byte, messageSize)
|
||||
n, err := d.Read(buffer)
|
||||
if err != nil {
|
||||
log(fmt.Sprintf("Datachannel closed; Exit the readloop: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
log(fmt.Sprintf("Message from DataChannel: %s\n", string(buffer[:n])))
|
||||
}
|
||||
}
|
||||
|
||||
// WriteLoop shows how to write to the datachannel directly
|
||||
func WriteLoop(d io.Writer) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
message, err := randutil.GenerateCryptoRandomString(messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
log(fmt.Sprintf("Sending %s \n", message))
|
||||
if _, err := d.Write([]byte(message)); err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func log(msg string) {
|
||||
el := getElementByID("logs")
|
||||
el.Set("innerHTML", el.Get("innerHTML").String()+msg+"<br>")
|
||||
}
|
||||
|
||||
func handleError(err error) {
|
||||
log("Unexpected error. Check console.")
|
||||
panic(err)
|
||||
}
|
||||
|
||||
func getElementByID(id string) js.Value {
|
||||
return js.Global().Get("document").Call("getElementById", id)
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
216
examples/data-channels-detach/main.go
Normal file
216
examples/data-channels-detach/main.go
Normal file
@@ -0,0 +1,216 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// data-channels-detach is an example that shows how you can detach a data channel.
|
||||
// This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel).
|
||||
// This allows you to interact with the data channel using a more idiomatic API based on
|
||||
// the `io.ReadWriteCloser` interface.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const messageSize = 15
|
||||
|
||||
func main() {
|
||||
// Since this behavior diverges from the WebRTC API it has to be
|
||||
// enabled using a settings engine. Mixing both detached and the
|
||||
// OnMessage DataChannel API is not supported.
|
||||
|
||||
// Create a SettingEngine and enable Detach
|
||||
s := webrtc.SettingEngine{}
|
||||
s.DetachDataChannels()
|
||||
|
||||
// Create an API object with the engine
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
|
||||
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection using the API object
|
||||
peerConnection, err := api.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Register data channel creation handling
|
||||
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
|
||||
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
|
||||
|
||||
// Register channel opening handling
|
||||
dataChannel.OnOpen(func() {
|
||||
fmt.Printf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID())
|
||||
|
||||
// Detach the data channel
|
||||
raw, dErr := dataChannel.Detach()
|
||||
if dErr != nil {
|
||||
panic(dErr)
|
||||
}
|
||||
|
||||
// Handle reading from the data channel
|
||||
go ReadLoop(raw)
|
||||
|
||||
// Handle writing to the data channel
|
||||
go WriteLoop(raw)
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for the offer to be pasted
|
||||
offer := webrtc.SessionDescription{}
|
||||
decode(readUntilNewline(), &offer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
err = peerConnection.SetRemoteDescription(offer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the answer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// ReadLoop shows how to read from the datachannel directly.
|
||||
func ReadLoop(d io.Reader) {
|
||||
for {
|
||||
buffer := make([]byte, messageSize)
|
||||
n, err := d.Read(buffer)
|
||||
if err != nil {
|
||||
fmt.Println("Datachannel closed; Exit the readloop:", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Message from DataChannel: %s\n", string(buffer[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
// WriteLoop shows how to write to the datachannel directly.
|
||||
func WriteLoop(d io.Writer) {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
message, err := randutil.GenerateCryptoRandomString(
|
||||
messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Sending %s \n", message)
|
||||
if _, err := d.Write([]byte(message)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
63
examples/data-channels-flow-control/README.md
Normal file
63
examples/data-channels-flow-control/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# data-channels-flow-control
|
||||
This example demonstrates how to use the following property / methods.
|
||||
|
||||
* func (d *DataChannel) BufferedAmount() uint64
|
||||
* func (d *DataChannel) SetBufferedAmountLowThreshold(th uint64)
|
||||
* func (d *DataChannel) BufferedAmountLowThreshold() uint64
|
||||
* func (d *DataChannel) OnBufferedAmountLow(f func())
|
||||
|
||||
These methods are equivalent to that of JavaScript WebRTC API.
|
||||
See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel for more details.
|
||||
|
||||
## When do we need it?
|
||||
Send or SendText methods are called on DataChannel to send data to the connected peer.
|
||||
The methods return immediately, but it does not mean the data was actually sent onto
|
||||
the wire. Instead, it is queued in a buffer until it actually gets sent out to the wire.
|
||||
|
||||
When you have a large amount of data to send, it is an application's responsibility to
|
||||
control the buffered amount in order not to indefinitely grow the buffer size to eventually
|
||||
exhaust the memory.
|
||||
|
||||
The rate you wish to send data might be much higher than the rate the data channel can
|
||||
actually send to the peer over the Internet. The above properties/methods help your
|
||||
application to pace the amount of data to be pushed into the data channel.
|
||||
|
||||
|
||||
## How to run the example code
|
||||
|
||||
The demo code (main.go) implements two endpoints (offerPC and answerPC) in it.
|
||||
|
||||
```
|
||||
signaling messages
|
||||
+----------------------------------------+
|
||||
| |
|
||||
v v
|
||||
+---------------+ +---------------+
|
||||
| | data | |
|
||||
| offerPC |----------------------->| answerPC |
|
||||
|:PeerConnection| |:PeerConnection|
|
||||
+---------------+ +---------------+
|
||||
```
|
||||
|
||||
First offerPC and answerPC will exchange signaling message to establish a peer-to-peer
|
||||
connection, and data channel (label: "data").
|
||||
|
||||
Once the data channel is successfully opened, offerPC will start sending a series of
|
||||
1024-byte packets to answerPC as fast as it can, until you kill the process by Ctrl-c.
|
||||
|
||||
|
||||
Here's how to run the code.
|
||||
|
||||
At the root of the example, `pion/webrtc/examples/data-channels-flow-control/`:
|
||||
```
|
||||
$ go run main.go
|
||||
2019/08/31 14:56:41 OnOpen: data-824635025728. Start sending a series of 1024-byte packets as fast as it can
|
||||
2019/08/31 14:56:41 OnOpen: data-824637171120. Start receiving data
|
||||
2019/08/31 14:56:42 Throughput: 179.118 Mbps
|
||||
2019/08/31 14:56:43 Throughput: 203.545 Mbps
|
||||
2019/08/31 14:56:44 Throughput: 211.516 Mbps
|
||||
2019/08/31 14:56:45 Throughput: 216.292 Mbps
|
||||
2019/08/31 14:56:46 Throughput: 217.961 Mbps
|
||||
2019/08/31 14:56:47 Throughput: 218.342 Mbps
|
||||
:
|
||||
```
|
||||
227
examples/data-channels-flow-control/main.go
Normal file
227
examples/data-channels-flow-control/main.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// data-channels-flow-control demonstrates how to use the DataChannel congestion control APIs
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
bufferedAmountLowThreshold uint64 = 512 * 1024 // 512 KB
|
||||
maxBufferedAmount uint64 = 1024 * 1024 // 1 MB
|
||||
)
|
||||
|
||||
func check(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setRemoteDescription(pc *webrtc.PeerConnection, sdp []byte) {
|
||||
var desc webrtc.SessionDescription
|
||||
err := json.Unmarshal(sdp, &desc)
|
||||
check(err)
|
||||
|
||||
// Apply the desc as the remote description
|
||||
err = pc.SetRemoteDescription(desc)
|
||||
check(err)
|
||||
}
|
||||
|
||||
func createOfferer() *webrtc.PeerConnection {
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{},
|
||||
}
|
||||
|
||||
// Create a new PeerConnection
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
check(err)
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
|
||||
ordered := false
|
||||
maxRetransmits := uint16(0)
|
||||
|
||||
options := &webrtc.DataChannelInit{
|
||||
Ordered: &ordered,
|
||||
MaxRetransmits: &maxRetransmits,
|
||||
}
|
||||
|
||||
sendMoreCh := make(chan struct{}, 1)
|
||||
|
||||
// Create a datachannel with label 'data'
|
||||
dataChannel, err := pc.CreateDataChannel("data", options)
|
||||
check(err)
|
||||
|
||||
// Register channel opening handling
|
||||
dataChannel.OnOpen(func() {
|
||||
log.Printf(
|
||||
"OnOpen: %s-%d. Start sending a series of 1024-byte packets as fast as it can\n",
|
||||
dataChannel.Label(), dataChannel.ID(),
|
||||
)
|
||||
|
||||
for {
|
||||
err2 := dataChannel.Send(buf)
|
||||
check(err2)
|
||||
|
||||
if dataChannel.BufferedAmount() > maxBufferedAmount {
|
||||
// Wait until the bufferedAmount becomes lower than the threshold
|
||||
<-sendMoreCh
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set bufferedAmountLowThreshold so that we can get notified when
|
||||
// we can send more
|
||||
dataChannel.SetBufferedAmountLowThreshold(bufferedAmountLowThreshold)
|
||||
|
||||
// This callback is made when the current bufferedAmount becomes lower than the threshold
|
||||
dataChannel.OnBufferedAmountLow(func() {
|
||||
select {
|
||||
case sendMoreCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
return pc
|
||||
}
|
||||
|
||||
func createAnswerer() *webrtc.PeerConnection {
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{},
|
||||
}
|
||||
|
||||
// Create a new PeerConnection
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
check(err)
|
||||
|
||||
pc.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
|
||||
var totalBytesReceived uint64
|
||||
|
||||
// Register channel opening handling
|
||||
dataChannel.OnOpen(func() {
|
||||
log.Printf("OnOpen: %s-%d. Start receiving data", dataChannel.Label(), dataChannel.ID())
|
||||
since := time.Now()
|
||||
|
||||
// Start printing out the observed throughput
|
||||
ticker := time.NewTicker(1000 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
bps := float64(atomic.LoadUint64(&totalBytesReceived)*8) / time.Since(since).Seconds()
|
||||
log.Printf("Throughput: %.03f Mbps", bps/1024/1024)
|
||||
}
|
||||
})
|
||||
|
||||
// Register the OnMessage to handle incoming messages
|
||||
dataChannel.OnMessage(func(dcMsg webrtc.DataChannelMessage) {
|
||||
n := len(dcMsg.Data)
|
||||
atomic.AddUint64(&totalBytesReceived, uint64(n))
|
||||
})
|
||||
})
|
||||
|
||||
return pc
|
||||
}
|
||||
|
||||
func main() {
|
||||
offerPC := createOfferer()
|
||||
defer func() {
|
||||
if err := offerPC.Close(); err != nil {
|
||||
fmt.Printf("cannot close offerPC: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
answerPC := createAnswerer()
|
||||
defer func() {
|
||||
if err := answerPC.Close(); err != nil {
|
||||
fmt.Printf("cannot close answerPC: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
|
||||
// send it to the other peer
|
||||
answerPC.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate != nil {
|
||||
check(offerPC.AddICECandidate(candidate.ToJSON()))
|
||||
}
|
||||
})
|
||||
|
||||
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
|
||||
// send it to the other peer
|
||||
offerPC.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate != nil {
|
||||
check(answerPC.AddICECandidate(candidate.ToJSON()))
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
offerPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s (offerer)\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
answerPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s (answerer)\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Now, create an offer
|
||||
offer, err := offerPC.CreateOffer(nil)
|
||||
check(err)
|
||||
check(offerPC.SetLocalDescription(offer))
|
||||
desc, err := json.Marshal(offer)
|
||||
check(err)
|
||||
|
||||
setRemoteDescription(answerPC, desc)
|
||||
|
||||
answer, err := answerPC.CreateAnswer(nil)
|
||||
check(err)
|
||||
check(answerPC.SetLocalDescription(answer))
|
||||
desc2, err := json.Marshal(answer)
|
||||
check(err)
|
||||
|
||||
setRemoteDescription(offerPC, desc2)
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
23
examples/data-channels-simple/README.md
Normal file
23
examples/data-channels-simple/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# WebRTC DataChannel Example in Go
|
||||
|
||||
This is a minimal example of a **WebRTC DataChannel** using **Go (Pion)** as the signaling server.
|
||||
|
||||
## Features
|
||||
|
||||
- Go server for signaling
|
||||
- Browser-based DataChannel
|
||||
- ICE candidate exchange
|
||||
- Real-time messaging between browser and Go server
|
||||
|
||||
## Usage
|
||||
|
||||
1. Run the server:
|
||||
|
||||
```
|
||||
go run main.go
|
||||
```
|
||||
|
||||
2. Open browser at http://localhost:8080
|
||||
|
||||
3. Send messages via DataChannel and see them in terminal & browser logs.
|
||||
|
||||
88
examples/data-channels-simple/demo.html
Normal file
88
examples/data-channels-simple/demo.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>DataChannel Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>📡 WebRTC DataChannel Test</h2>
|
||||
<input id="msg" placeholder="Message">
|
||||
<button id="sendBtn" disabled onclick="sendMsg()">Send</button>
|
||||
<pre id="log"></pre>
|
||||
|
||||
<script>
|
||||
const pc = new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]});
|
||||
const channel = pc.createDataChannel("chat");
|
||||
|
||||
// Connection state monitoring
|
||||
pc.onconnectionstatechange = () => log(`🔄 Connection state: ${pc.connectionState}`);
|
||||
pc.oniceconnectionstatechange = () => log(`🧊 ICE state: ${pc.iceConnectionState}`);
|
||||
pc.onsignalingstatechange = () => log(`📞 Signaling state: ${pc.signalingState}`);
|
||||
|
||||
channel.onopen = () => {
|
||||
log("✅ DataChannel opened");
|
||||
document.getElementById("sendBtn").disabled = false;
|
||||
}
|
||||
channel.onmessage = e => log(`📩 Server: ${e.data}`);
|
||||
|
||||
pc.onicecandidate = event => {
|
||||
if(event.candidate){
|
||||
fetch("/candidate", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(event.candidate),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function start(){
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
const res = await fetch("/offer", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(offer),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const answer = await res.json();
|
||||
await pc.setRemoteDescription(answer);
|
||||
|
||||
} catch (err) {
|
||||
log(`❌ Connection failed: ${err.message}`);
|
||||
console.error("Connection error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function sendMsg(){
|
||||
if(channel.readyState !== "open"){
|
||||
log("❌ Channel not open yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = document.getElementById("msg").value;
|
||||
|
||||
if (msg.trim()) {
|
||||
channel.send(msg);
|
||||
log(`You: ${msg}`);
|
||||
document.getElementById("msg").value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function log(msg){
|
||||
document.getElementById("log").textContent+=msg+"\n";
|
||||
}
|
||||
|
||||
start();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
148
examples/data-channels-simple/main.go
Normal file
148
examples/data-channels-simple/main.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// simple-datachannel is a simple datachannel demo that auto connects.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var pc *webrtc.PeerConnection
|
||||
|
||||
setupOfferHandler(&pc)
|
||||
setupCandidateHandler(&pc)
|
||||
setupStaticHandler()
|
||||
|
||||
fmt.Println("🚀 Signaling server started on http://localhost:8080")
|
||||
//nolint:gosec
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
fmt.Printf("Failed to start server: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupOfferHandler(pc **webrtc.PeerConnection) {
|
||||
http.HandleFunc("/offer", func(responseWriter http.ResponseWriter, r *http.Request) {
|
||||
var offer webrtc.SessionDescription
|
||||
if err := json.NewDecoder(r.Body).Decode(&offer); err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// PeerConnection with enhanced configuration for better browser compatibility
|
||||
var err error
|
||||
*pc, err = webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
},
|
||||
BundlePolicy: webrtc.BundlePolicyBalanced,
|
||||
RTCPMuxPolicy: webrtc.RTCPMuxPolicyRequire,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setupICECandidateHandler(*pc)
|
||||
setupDataChannelHandler(*pc)
|
||||
|
||||
if err := processOffer(*pc, offer, responseWriter); err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupICECandidateHandler(pc *webrtc.PeerConnection) {
|
||||
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
|
||||
if c != nil {
|
||||
fmt.Printf("🌐 New ICE candidate: %s\n", c.Address)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupDataChannelHandler(pc *webrtc.PeerConnection) {
|
||||
pc.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
d.OnOpen(func() {
|
||||
fmt.Println("✅ DataChannel opened (Server)")
|
||||
if sendErr := d.SendText("Hello from Go server 👋"); sendErr != nil {
|
||||
fmt.Printf("Failed to send text: %v\n", sendErr)
|
||||
}
|
||||
})
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
fmt.Printf("📩 Received: %s\n", string(msg.Data))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func processOffer(
|
||||
pc *webrtc.PeerConnection,
|
||||
offer webrtc.SessionDescription,
|
||||
responseWriter http.ResponseWriter,
|
||||
) error {
|
||||
// Set remote description
|
||||
if err := pc.SetRemoteDescription(offer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set local description
|
||||
if err := pc.SetLocalDescription(answer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for ICE gathering to complete before sending answer
|
||||
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||
<-gatherComplete
|
||||
|
||||
finalAnswer := pc.LocalDescription()
|
||||
if finalAnswer == nil {
|
||||
//nolint:err113
|
||||
return fmt.Errorf("local description is nil after ICE gathering")
|
||||
}
|
||||
|
||||
responseWriter.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(responseWriter).Encode(*finalAnswer); err != nil {
|
||||
fmt.Printf("Failed to encode answer: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupCandidateHandler(pc **webrtc.PeerConnection) {
|
||||
http.HandleFunc("/candidate", func(responseWriter http.ResponseWriter, r *http.Request) {
|
||||
var candidate webrtc.ICECandidateInit
|
||||
if err := json.NewDecoder(r.Body).Decode(&candidate); err != nil {
|
||||
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
if *pc != nil {
|
||||
if err := (*pc).AddICECandidate(candidate); err != nil {
|
||||
fmt.Println("Failed to add candidate", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setupStaticHandler() {
|
||||
// demo.html
|
||||
http.HandleFunc("/", func(responseWriter http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(responseWriter, r, "./demo.html")
|
||||
})
|
||||
}
|
||||
63
examples/data-channels-whip-whep-like/README.md
Normal file
63
examples/data-channels-whip-whep-like/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# whip-whep-like
|
||||
|
||||
This example demonstrates a WHIP/WHEP-like implementation using Pion WebRTC with DataChannel support for real-time chat.
|
||||
|
||||
**Note:** This is similar to but not exactly WHIP/WHEP, as the official WHIP/WHEP specifications focus on media streaming only and do not include DataChannel support. This example extends the WHIP/WHEP pattern to demonstrate peer-to-peer chat functionality with automatic username assignment and message broadcasting.
|
||||
|
||||
Key features:
|
||||
- **Real-time chat** with WebRTC DataChannels
|
||||
- **Automatic username generation** - Each user gets a unique random username (e.g., SneakyBear46)
|
||||
- **Message broadcasting** - All connected users receive messages from everyone else
|
||||
- **WHIP/WHEP-like signaling** - Simple HTTP-based signaling for easy integration
|
||||
|
||||
Further details about WHIP+WHEP and the WebRTC DataChannel implementation are below the instructions.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download the example
|
||||
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/data-channels-whip-whep-like
|
||||
```
|
||||
|
||||
### Run the server
|
||||
Execute `go run *.go`
|
||||
|
||||
### Connect and chat
|
||||
|
||||
1. Open [http://localhost:8080](http://localhost:8080) in your browser
|
||||
2. Click "Publish" or "Subscribe" to establish a DataChannel connection
|
||||
3. You'll be assigned a random username (e.g., "SneakyBear46")
|
||||
4. Type a message and click "Send Message" to broadcast to all connected users
|
||||
5. Open multiple tabs/windows to test multi-user chat
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
|
||||
## Why WHIP/WHEP for signaling?
|
||||
|
||||
This example uses a WHIP/WHEP-like signaling approach where an Offer is uploaded via HTTP and the server responds with an Answer. This simple API contract makes it easy to integrate WebRTC into web applications.
|
||||
|
||||
**Difference from standard WHIP/WHEP:** The official WHIP/WHEP specifications are designed for media streaming (audio/video) only. This example extends that pattern to include DataChannel support for real-time chat functionality.
|
||||
|
||||
## Implementation details
|
||||
|
||||
### Username generation
|
||||
Each connected user is automatically assigned a unique username combining:
|
||||
- An adjective (e.g., Sneaky, Brave, Quick)
|
||||
- An animal noun (e.g., Bear, Fox, Eagle)
|
||||
- A random number (0-999)
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
|
||||
## Why WHIP/WHEP?
|
||||
|
||||
WHIP/WHEP mandates that a Offer is uploaded via HTTP. The server responds with a Answer. With this strong API contract WebRTC support can be added to tools like OBS.
|
||||
|
||||
For more info on WHIP/WHEP specification, feel free to read some of these great resources:
|
||||
- https://webrtchacks.com/webrtc-cracks-the-whip-on-obs/
|
||||
- https://datatracker.ietf.org/doc/draft-ietf-wish-whip/
|
||||
- https://datatracker.ietf.org/doc/draft-ietf-wish-whep/
|
||||
- https://bloggeek.me/whip-whep-webrtc-live-streaming
|
||||
97
examples/data-channels-whip-whep-like/index.html
Normal file
97
examples/data-channels-whip-whep-like/index.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<html>
|
||||
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<title>whip-whep</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button onclick="window.doWHIP()">Publish</button>
|
||||
<button onclick="window.doWHEP()">Subscribe</button>
|
||||
<br />
|
||||
Message<br />
|
||||
<textarea id="message">This is my DataChannel message!</textarea> <br/>
|
||||
<button onclick="window.sendMessage()">Send Message</button> <br />
|
||||
<h3> Logs </h3>
|
||||
<div id="logs"></div>
|
||||
|
||||
|
||||
<h3> ICE Connection States </h3>
|
||||
<div id="iceConnectionStates"></div> <br />
|
||||
</body>
|
||||
|
||||
<script>
|
||||
const log = msg => {
|
||||
document.getElementById('logs').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
let peerConnection = new RTCPeerConnection()
|
||||
|
||||
const sendChannel = peerConnection.createDataChannel('foo')
|
||||
sendChannel.onclose = () => console.log('sendChannel has closed')
|
||||
sendChannel.onopen = () => console.log('sendChannel has opened')
|
||||
sendChannel.onmessage = e => log(`From ${e.data}`)
|
||||
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
let el = document.createElement('p')
|
||||
el.appendChild(document.createTextNode(peerConnection.iceConnectionState))
|
||||
|
||||
document.getElementById('iceConnectionStates').appendChild(el);
|
||||
}
|
||||
|
||||
window.doWHEP = () => {
|
||||
|
||||
peerConnection.createOffer().then(offer => {
|
||||
peerConnection.setLocalDescription(offer)
|
||||
|
||||
fetch(`/whep`, {
|
||||
method: 'POST',
|
||||
body: offer.sdp,
|
||||
headers: {
|
||||
Authorization: `Bearer none`,
|
||||
'Content-Type': 'application/sdp'
|
||||
}
|
||||
}).then(r => r.text())
|
||||
.then(answer => {
|
||||
peerConnection.setRemoteDescription({
|
||||
sdp: answer,
|
||||
type: 'answer'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
window.doWHIP = () => {
|
||||
peerConnection.createOffer().then(offer => {
|
||||
peerConnection.setLocalDescription(offer)
|
||||
|
||||
fetch(`/whip`, {
|
||||
method: 'POST',
|
||||
body: offer.sdp,
|
||||
headers: {
|
||||
Authorization: `Bearer none`,
|
||||
'Content-Type': 'application/sdp'
|
||||
}
|
||||
}).then(r => r.text())
|
||||
.then(answer => {
|
||||
peerConnection.setRemoteDescription({
|
||||
sdp: answer,
|
||||
type: 'answer'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
window.sendMessage = () => {
|
||||
const message = document.getElementById('message').value
|
||||
if (message === '') {
|
||||
return alert('Message must not be empty')
|
||||
}
|
||||
|
||||
sendChannel.send(message)
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
257
examples/data-channels-whip-whep-like/main.go
Normal file
257
examples/data-channels-whip-whep-like/main.go
Normal file
@@ -0,0 +1,257 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// whip-whep demonstrates how to use the WHIP/WHEP specifications to exchange SPD descriptions
|
||||
// and stream media to a WebRTC client in the browser or OBS.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint: gochecknoglobals
|
||||
var (
|
||||
peerConnectionConfiguration = webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Broadcast hub to forward messages between all connected clients.
|
||||
broadcastHub = &Hub{
|
||||
connections: make(map[*webrtc.DataChannel]bool),
|
||||
usernames: make(map[*webrtc.DataChannel]string),
|
||||
mu: sync.RWMutex{},
|
||||
}
|
||||
)
|
||||
|
||||
// Hub manages all connected DataChannels for broadcasting.
|
||||
type Hub struct {
|
||||
connections map[*webrtc.DataChannel]bool
|
||||
usernames map[*webrtc.DataChannel]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// nolint: gochecknoglobals
|
||||
var (
|
||||
adjectives = []string{
|
||||
"Quick", "Swift", "Bright", "Bold", "Calm", "Cool", "Fast", "Happy",
|
||||
"Lucky", "Shy", "Sneaky", "Wise", "Brave", "Clever", "Kind", "Proud",
|
||||
}
|
||||
nouns = []string{
|
||||
"Fox", "Eagle", "Lion", "Tiger", "Wolf", "Dragon", "Hawk", "Bear",
|
||||
"Shark", "Falcon", "Leopard", "Panther", "Phoenix", "Raven", "Crow", "Owl",
|
||||
}
|
||||
)
|
||||
|
||||
// Register adds a DataChannel to the broadcast hub and assigns a random username.
|
||||
func (h *Hub) Register(channel *webrtc.DataChannel) string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.connections[channel] = true
|
||||
|
||||
username := h.generateUniqueUsername()
|
||||
h.usernames[channel] = username
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// Unregister removes a DataChannel from the broadcast hub.
|
||||
func (h *Hub) Unregister(channel *webrtc.DataChannel) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.connections, channel)
|
||||
delete(h.usernames, channel)
|
||||
}
|
||||
|
||||
// generateUniqueUsername generates a unique username by combining an adjective and a noun.
|
||||
// It checks existing usernames and regenerates until it finds a unique one.
|
||||
// Must be called while holding h.mu.Lock().
|
||||
// nolint: gosec
|
||||
func (h *Hub) generateUniqueUsername() string {
|
||||
var username string
|
||||
for {
|
||||
adjective := adjectives[rand.Intn(len(adjectives))]
|
||||
noun := nouns[rand.Intn(len(nouns))]
|
||||
number := rand.Intn(1000)
|
||||
username = fmt.Sprintf("%s%s%d", adjective, noun, number)
|
||||
|
||||
// Check if this username already exists by iterating over map values directly
|
||||
exists := false
|
||||
for _, existingUsername := range h.usernames {
|
||||
if existingUsername == username {
|
||||
exists = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// GetUsername returns the username for a DataChannel.
|
||||
func (h *Hub) GetUsername(channel *webrtc.DataChannel) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
return h.usernames[channel]
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all registered DataChannels including the sender.
|
||||
func (h *Hub) Broadcast(message string, sender *webrtc.DataChannel) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
// Get the sender's username
|
||||
senderUsername := h.usernames[sender]
|
||||
formattedMessage := fmt.Sprintf("%s: %s", senderUsername, message)
|
||||
|
||||
for channel := range h.connections {
|
||||
// Check if channel is still open
|
||||
if channel.ReadyState() != webrtc.DataChannelStateOpen {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send message in goroutine to avoid blocking
|
||||
go func(ch *webrtc.DataChannel, msg string) {
|
||||
if err := ch.SendText(msg); err != nil {
|
||||
fmt.Printf("Failed to send broadcast message: %v\n", err)
|
||||
}
|
||||
}(channel, formattedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the number of connected clients.
|
||||
func (h *Hub) Count() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
return len(h.connections)
|
||||
}
|
||||
|
||||
// nolint:gocognit
|
||||
func main() {
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
http.Handle("/", http.FileServer(http.Dir(".")))
|
||||
http.HandleFunc("/whep", whepHandler)
|
||||
http.HandleFunc("/whip", whipHandler)
|
||||
|
||||
fmt.Println("Open http://localhost:8080 to access this demo")
|
||||
panic(http.ListenAndServe(":8080", nil)) // nolint: gosec
|
||||
}
|
||||
|
||||
func whipHandler(res http.ResponseWriter, req *http.Request) {
|
||||
// Read the offer from HTTP Request
|
||||
offer, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfiguration)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send answer via HTTP Response
|
||||
writeAnswer(res, peerConnection, offer, "/whip")
|
||||
}
|
||||
|
||||
func whepHandler(res http.ResponseWriter, req *http.Request) {
|
||||
// Read the offer from HTTP Request
|
||||
offer, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfiguration)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send answer via HTTP Response
|
||||
writeAnswer(res, peerConnection, offer, "/whep")
|
||||
}
|
||||
|
||||
func writeAnswer(res http.ResponseWriter, peerConnection *webrtc.PeerConnection, offer []byte, path string) {
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
|
||||
|
||||
if connectionState == webrtc.ICEConnectionStateFailed ||
|
||||
connectionState == webrtc.ICEConnectionStateClosed {
|
||||
_ = peerConnection.Close()
|
||||
}
|
||||
})
|
||||
|
||||
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
|
||||
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
|
||||
|
||||
dataChannel.OnOpen(func() {
|
||||
// register this channel in the broadcast hub and get assigned username
|
||||
username := broadcastHub.Register(dataChannel)
|
||||
fmt.Printf("Data channel '%s'-'%d' opened. Username: %s, Total clients: %d\n",
|
||||
dataChannel.Label(), dataChannel.ID(), username, broadcastHub.Count())
|
||||
})
|
||||
|
||||
dataChannel.OnClose(func() {
|
||||
fmt.Printf("Data channel '%s'-'%d' closed\n", dataChannel.Label(), dataChannel.ID())
|
||||
// unregister this channel from the broadcast hub
|
||||
broadcastHub.Unregister(dataChannel)
|
||||
})
|
||||
|
||||
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
message := string(msg.Data)
|
||||
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), message)
|
||||
|
||||
// broadcast the message to all other connected clients
|
||||
broadcastHub.Broadcast(message, dataChannel)
|
||||
})
|
||||
})
|
||||
|
||||
if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer, SDP: string(offer),
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// WHIP+WHEP expects a Location header and a HTTP Status Code of 201
|
||||
res.Header().Add("Location", path)
|
||||
res.WriteHeader(http.StatusCreated)
|
||||
|
||||
// Write Answer with Candidates as HTTP Response
|
||||
fmt.Fprint(res, peerConnection.LocalDescription().SDP) //nolint: errcheck
|
||||
}
|
||||
80
examples/data-channels/README.md
Normal file
80
examples/data-channels/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# data-channels
|
||||
data-channels is Pion's sample WebRTC app that lets you send and receive DataChannel messages from a web browser.
|
||||
|
||||
## Brief Overview
|
||||
This example will result in messages being sent between a browser and a self-hosted data-channels server. The connection is made by grabbing the browser's generated session description, or SDP, and passing it into the server. The server uses the browser's SDP to then return a new SDP from the server based on the browser's SDP. The server's SDP then gets passed back into the browser which confirms the handshake and forms a connection!
|
||||
|
||||
Once the connection is established, messages will automatically be sent from the data-channels server to the browser every 5 seconds. The browser has a button that lets you send a message back to the server when you click on it.
|
||||
|
||||
## Instructions
|
||||
### 1. Download the data-channels server
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/data-channels@latest
|
||||
```
|
||||
|
||||
### 2. Open JSFiddle
|
||||
[Open this JSFiddle example page.](https://jsfiddle.net/e41tgovp/)
|
||||
The top of the JSFiddle example page contains a text box containing your browser's session description (SDP).
|
||||
Press `Copy browser SDP to clipboard` or copy the base64 string manually.
|
||||
|
||||
### 3. Send the browser's SDP to the server
|
||||
Depending on your OS:
|
||||
|
||||
#### Linux/macOS (including WSL)
|
||||
In the following command, replace `$BROWSER_SDP` with the copied string.
|
||||
Run `echo $BROWSER_SDP | data-channels`.
|
||||
|
||||
#### Windows
|
||||
1. Paste the copied string into a file.
|
||||
2. Run `data-channels < my_file`.
|
||||
|
||||
### 4. Send the server's SDP back to the browser
|
||||
The server will automatically print out a base64 string. Copy it and paste it into the second textbox in the JSFiddle page.
|
||||
|
||||
### 5. Start the session!
|
||||
Under Start Session you should see 'Checking' as it starts connecting. If everything worked you should see `New DataChannel foo 1`.
|
||||
|
||||
Pion WebRTC will send random messages every 5 seconds that will appear in your browser.
|
||||
|
||||
### 6. Send a message from the browser to the server!
|
||||
You can put whatever you want in the `Message` text area, and when you hit `Send Message` it should appear in your terminal!
|
||||
|
||||
## Example finished!
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool :)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Browser--Copy Offer from TextArea-->Pion
|
||||
Pion--Copy Text Print to Console-->Browser
|
||||
subgraph Pion[Go Peer]
|
||||
p1[Create PeerConnection]
|
||||
p2[OnConnectionState Handler]
|
||||
p3[Print Connection State]
|
||||
p2-->p3
|
||||
p4[OnDataChannel Handler]
|
||||
p5[OnDataChannel Open]
|
||||
p6[Send Random Message every 5 seconds to DataChannel]
|
||||
p4-->p5-->p6
|
||||
p7[OnDataChannel Message]
|
||||
p8[Log Incoming Message to Console]
|
||||
p4-->p7-->p8
|
||||
p9[Read Session Description from Standard Input]
|
||||
p10[SetRemoteDescription with Session Description from Standard Input]
|
||||
p11[Create Answer]
|
||||
p12[Block until ICE Gathering is Complete]
|
||||
p13[Print Answer with ICE Candidatens included to Standard Output]
|
||||
end
|
||||
subgraph Browser[Browser Peer]
|
||||
b1[Create PeerConnection]
|
||||
b2[Create DataChannel 'foo']
|
||||
b3[OnDataChannel Message]
|
||||
b4[Log Incoming Message to Console]
|
||||
b3-->b4
|
||||
b5[Create Offer]
|
||||
b6[SetLocalDescription with Offer]
|
||||
b7[Print Offer with ICE Candidates included]
|
||||
|
||||
end
|
||||
```
|
||||
8
examples/data-channels/jsfiddle/demo.css
Normal file
8
examples/data-channels/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/data-channels/jsfiddle/demo.details
Normal file
8
examples/data-channels/jsfiddle/demo.details
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: data-channels
|
||||
description: Example of using Pion WebRTC to communicate with a web browser using bi-direction DataChannels
|
||||
authors:
|
||||
- Sean DuBois
|
||||
25
examples/data-channels/jsfiddle/demo.html
Normal file
25
examples/data-channels/jsfiddle/demo.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
Browser base64 Session Description<br />
|
||||
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
|
||||
<button onclick="window.copySDP()">
|
||||
Copy browser SDP to clipboard
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
Golang base64 Session Description<br />
|
||||
<textarea id="remoteSessionDescription"></textarea><br/>
|
||||
<button onclick="window.startSession()">Start Session</button><br />
|
||||
|
||||
<br />
|
||||
|
||||
Message<br />
|
||||
<textarea id="message">This is my DataChannel message!</textarea> <br/>
|
||||
<button onclick="window.sendMessage()">Send Message</button> <br />
|
||||
|
||||
<br />
|
||||
Logs<br />
|
||||
<div id="logs"></div>
|
||||
67
examples/data-channels/jsfiddle/demo.js
Normal file
67
examples/data-channels/jsfiddle/demo.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}
|
||||
]
|
||||
})
|
||||
const log = msg => {
|
||||
document.getElementById('logs').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
const sendChannel = pc.createDataChannel('foo')
|
||||
sendChannel.onclose = () => console.log('sendChannel has closed')
|
||||
sendChannel.onopen = () => console.log('sendChannel has opened')
|
||||
sendChannel.onmessage = e => log(`Message from DataChannel '${sendChannel.label}' payload '${e.data}'`)
|
||||
|
||||
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
|
||||
pc.onicecandidate = event => {
|
||||
if (event.candidate === null) {
|
||||
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
|
||||
}
|
||||
}
|
||||
|
||||
pc.onnegotiationneeded = e =>
|
||||
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
|
||||
|
||||
window.sendMessage = () => {
|
||||
const message = document.getElementById('message').value
|
||||
if (message === '') {
|
||||
return alert('Message must not be empty')
|
||||
}
|
||||
|
||||
sendChannel.send(message)
|
||||
}
|
||||
|
||||
window.startSession = () => {
|
||||
const sd = document.getElementById('remoteSessionDescription').value
|
||||
if (sd === '') {
|
||||
return alert('Session Description must not be empty')
|
||||
}
|
||||
|
||||
try {
|
||||
pc.setRemoteDescription(JSON.parse(atob(sd)))
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}
|
||||
|
||||
window.copySDP = () => {
|
||||
const browserSDP = document.getElementById('localSessionDescription')
|
||||
|
||||
browserSDP.focus()
|
||||
browserSDP.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
const msg = successful ? 'successful' : 'unsuccessful'
|
||||
log('Copying SDP was ' + msg)
|
||||
} catch (err) {
|
||||
log('Unable to copy SDP ' + err)
|
||||
}
|
||||
}
|
||||
202
examples/data-channels/jsfiddle/main.go
Normal file
202
examples/data-channels/jsfiddle/main.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build js && wasm
|
||||
// +build js,wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configure and create a new PeerConnection.
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
// Create DataChannel.
|
||||
sendChannel, err := pc.CreateDataChannel("foo", nil)
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
sendChannel.OnClose(func() {
|
||||
fmt.Println("sendChannel has closed")
|
||||
})
|
||||
sendChannel.OnClosing(func() {
|
||||
fmt.Println("sendChannel is closing")
|
||||
})
|
||||
sendChannel.OnError(func(err error) {
|
||||
fmt.Println("sendChannel error", err)
|
||||
})
|
||||
sendChannel.OnOpen(func() {
|
||||
fmt.Println("sendChannel has opened")
|
||||
|
||||
candidatePair, err := pc.SCTP().Transport().ICETransport().GetSelectedCandidatePair()
|
||||
|
||||
fmt.Println(candidatePair)
|
||||
fmt.Println(err)
|
||||
})
|
||||
sendChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
log(fmt.Sprintf("Message from DataChannel %s payload %s", sendChannel.Label(), string(msg.Data)))
|
||||
})
|
||||
|
||||
// Create offer
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
// Add handlers for setting up the connection.
|
||||
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
log(fmt.Sprint(state))
|
||||
})
|
||||
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate != nil {
|
||||
encodedDescr := encode(pc.LocalDescription())
|
||||
el := getElementByID("localSessionDescription")
|
||||
el.Set("value", encodedDescr)
|
||||
}
|
||||
})
|
||||
|
||||
// Set up global callbacks which will be triggered on button clicks.
|
||||
js.Global().Set("sendMessage", js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
go func() {
|
||||
el := getElementByID("message")
|
||||
message := el.Get("value").String()
|
||||
if message == "" {
|
||||
js.Global().Call("alert", "Message must not be empty")
|
||||
return
|
||||
}
|
||||
if err := sendChannel.SendText(message); err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
}()
|
||||
return js.Undefined()
|
||||
}))
|
||||
js.Global().Set("startSession", js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
go func() {
|
||||
el := getElementByID("remoteSessionDescription")
|
||||
sd := el.Get("value").String()
|
||||
if sd == "" {
|
||||
js.Global().Call("alert", "Session Description must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
descr := webrtc.SessionDescription{}
|
||||
decode(sd, &descr)
|
||||
if err := pc.SetRemoteDescription(descr); err != nil {
|
||||
handleError(err)
|
||||
}
|
||||
}()
|
||||
return js.Undefined()
|
||||
}))
|
||||
js.Global().Set("copySDP", js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
go func() {
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
switch e := e.(type) {
|
||||
case error:
|
||||
handleError(e)
|
||||
default:
|
||||
handleError(fmt.Errorf("recovered with non-error value: (%T) %s", e, e))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
browserSDP := getElementByID("localSessionDescription")
|
||||
|
||||
browserSDP.Call("focus")
|
||||
browserSDP.Call("select")
|
||||
|
||||
copyStatus := js.Global().Get("document").Call("execCommand", "copy")
|
||||
if copyStatus.Bool() {
|
||||
log("Copying SDP was successful")
|
||||
} else {
|
||||
log("Copying SDP was unsuccessful")
|
||||
}
|
||||
}()
|
||||
return js.Undefined()
|
||||
}))
|
||||
|
||||
// Stay alive
|
||||
select {}
|
||||
}
|
||||
|
||||
func log(msg string) {
|
||||
el := getElementByID("logs")
|
||||
el.Set("innerHTML", el.Get("innerHTML").String()+msg+"<br>")
|
||||
}
|
||||
|
||||
func handleError(err error) {
|
||||
log("Unexpected error. Check console.")
|
||||
panic(err)
|
||||
}
|
||||
|
||||
func getElementByID(id string) js.Value {
|
||||
return js.Global().Get("document").Call("getElementById", id)
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
178
examples/data-channels/main.go
Normal file
178
examples/data-channels/main.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// data-channels is a Pion WebRTC application that shows how you can send/recv DataChannel messages from a web browser
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint:cyclop
|
||||
func main() {
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Register data channel creation handling
|
||||
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
|
||||
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
|
||||
|
||||
// Register channel opening handling
|
||||
dataChannel.OnOpen(func() {
|
||||
fmt.Printf(
|
||||
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
|
||||
dataChannel.Label(), dataChannel.ID(),
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
message, sendErr := randutil.GenerateCryptoRandomString(15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
if sendErr != nil {
|
||||
panic(sendErr)
|
||||
}
|
||||
|
||||
// Send the message as text
|
||||
fmt.Printf("Sending '%s'\n", message)
|
||||
if sendErr = dataChannel.SendText(message); sendErr != nil {
|
||||
panic(sendErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Register text message handling
|
||||
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), string(msg.Data))
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for the offer to be pasted
|
||||
offer := webrtc.SessionDescription{}
|
||||
decode(readUntilNewline(), &offer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
err = peerConnection.SetRemoteDescription(offer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create an answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the answer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
38
examples/example.html
Normal file
38
examples/example.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<link href="https://fonts.googleapis.com/css?family=Quicksand:400,500,700" rel="stylesheet">
|
||||
<title>{{ .Title }} | Pion</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 400;
|
||||
margin: 4em 10%;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" type="text/css" href="demo.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ .Title }}</h1>
|
||||
<p><a href="/">< Home</a></p>
|
||||
|
||||
<div>
|
||||
{{ template "demo.html" }}
|
||||
</div>
|
||||
</body>
|
||||
{{ if .JS }}
|
||||
<script src="demo.js"></script>
|
||||
{{ else }}
|
||||
<script src="/wasm_exec.js"></script>
|
||||
<script>
|
||||
const go = new Go();
|
||||
WebAssembly.instantiateStreaming(fetch("demo.wasm"), go.importObject).then((result) => {
|
||||
go.run(result.instance);
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
</html>
|
||||
152
examples/examples.go
Normal file
152
examples/examples.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// HTTP server that demonstrates Pion WebRTC examples
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"go/build"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Examples represents the examples loaded from examples.json.
|
||||
type Examples []*Example
|
||||
|
||||
// Example represents an example loaded from examples.json.
|
||||
type Example struct {
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
IsJS bool
|
||||
IsWASM bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("address", ":80", "Address to host the HTTP server on.")
|
||||
flag.Parse()
|
||||
|
||||
log.Println("Listening on", *addr)
|
||||
err := serve(*addr)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to serve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serve(addr string) error {
|
||||
// Load the examples
|
||||
examples := getExamples()
|
||||
|
||||
// Load the templates
|
||||
homeTemplate := template.Must(template.ParseFiles("index.html"))
|
||||
|
||||
// Serve the required pages
|
||||
// DIY 'mux' to avoid additional dependencies
|
||||
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
|
||||
url := req.URL.Path
|
||||
if url == "/wasm_exec.js" {
|
||||
http.FileServer(http.Dir(filepath.Join(build.Default.GOROOT, "lib/wasm/"))).ServeHTTP(res, req)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Split up the URL. Expected parts:
|
||||
// 1: Base url
|
||||
// 2: "example"
|
||||
// 3: Example type: js or wasm
|
||||
// 4: Example folder, e.g.: data-channels
|
||||
// 5: Static file as part of the example
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 4 &&
|
||||
parts[1] == "example" {
|
||||
exampleType := parts[2]
|
||||
exampleLink := parts[3]
|
||||
for _, example := range *examples {
|
||||
if example.Link != exampleLink {
|
||||
continue
|
||||
}
|
||||
fiddle := filepath.Join(exampleLink, "jsfiddle")
|
||||
if len(parts[4]) != 0 {
|
||||
http.StripPrefix(
|
||||
"/example/"+exampleType+"/"+exampleLink+"/",
|
||||
http.FileServer(http.Dir(fiddle)),
|
||||
).ServeHTTP(res, req)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
temp := template.Must(template.ParseFiles("example.html"))
|
||||
_, err := temp.ParseFiles(filepath.Join(fiddle, "demo.html"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
data := struct {
|
||||
*Example
|
||||
JS bool
|
||||
}{
|
||||
example,
|
||||
exampleType == "js",
|
||||
}
|
||||
|
||||
err = temp.Execute(res, data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Serve the main page
|
||||
err := homeTemplate.Execute(res, examples)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Start the server
|
||||
// nolint: gosec
|
||||
return http.ListenAndServe(addr, nil)
|
||||
}
|
||||
|
||||
// getExamples loads the examples from the examples.json file.
|
||||
func getExamples() *Examples {
|
||||
file, err := os.Open("./examples.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
closeErr := file.Close()
|
||||
if closeErr != nil {
|
||||
panic(closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
var examples Examples
|
||||
err = json.NewDecoder(file).Decode(&examples)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, example := range examples {
|
||||
fiddle := filepath.Join(example.Link, "jsfiddle")
|
||||
js := filepath.Join(fiddle, "demo.js")
|
||||
if _, err := os.Stat(js); !os.IsNotExist(err) {
|
||||
example.IsJS = true
|
||||
}
|
||||
wasm := filepath.Join(fiddle, "demo.wasm")
|
||||
if _, err := os.Stat(wasm); !os.IsNotExist(err) {
|
||||
example.IsWASM = true
|
||||
}
|
||||
}
|
||||
|
||||
return &examples
|
||||
}
|
||||
146
examples/examples.json
Normal file
146
examples/examples.json
Normal file
@@ -0,0 +1,146 @@
|
||||
[
|
||||
{
|
||||
"title": "Data Channels",
|
||||
"link": "data-channels",
|
||||
"description": "The data-channels example shows how you can send/recv DataChannel messages from a web browser.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Data Channels Detach",
|
||||
"link": "data-channels-detach",
|
||||
"description": "The data-channels-detach is an example that shows how you can detach a data channel.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Data Channels Flow Control",
|
||||
"link": "data-channels-flow-control",
|
||||
"description": "The data-channels-detach data-channels-flow-control shows how to use the DataChannel API efficiently. You can measure the amount the rate at which the remote peer is receiving data, and structure your application accordingly",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "SCTP Interleaving Inspector",
|
||||
"link": "sctp-interleaving",
|
||||
"description": "The sctp-interleaving example inspects plaintext SCTP packets between DTLS and SCTP so you can see whether I-DATA interleaving was advertised and observed.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Reflect",
|
||||
"link": "reflect",
|
||||
"description": "The reflect example demonstrates how to have Pion send back to the user exactly what it receives using the same PeerConnection.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Repacketize",
|
||||
"link": "repacketize",
|
||||
"description": "The repacketize example demonstrates how many video codecs can be received, depacketized and packetized by Pion over RTP.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Pion to Pion",
|
||||
"link": "#",
|
||||
"description": "Example pion-to-pion is an example of two pion instances communicating directly! It therefore has no corresponding web page.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Play from Disk",
|
||||
"link": "play-from-disk",
|
||||
"description": "The play-from-disk example demonstrates how to send video to your browser from a file saved to disk.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Play from Disk Renegotiation",
|
||||
"link": "play-from-disk",
|
||||
"description": "The play-from-disk-renegotiation example is an extension of the play-from-disk example, but demonstrates how you can add/remove video tracks from an already negotiated PeerConnection.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Play from Disk Audio Control",
|
||||
"link": "play-from-disk-playlist-control",
|
||||
"description": "The play-from-disk-playlist-control example demonstrates how to play an opus playlist from a file saved to disk, and control the playlist playback from the browser.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Insertable Streams",
|
||||
"link": "insertable-streams",
|
||||
"description": "The insertable-streams example demonstrates how Pion can be used to send E2E encrypted video and decrypt via insertable streams in the browser.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Save to Disk",
|
||||
"link": "save-to-disk",
|
||||
"description": "The save-to-disk example shows how to record your webcam and save the footage to disk on the server side.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Broadcast",
|
||||
"link": "broadcast",
|
||||
"description": "The broadcast example demonstrates how to broadcast a video to multiple peers. A broadcaster uploads the video once and the server forwards it to all other peers.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "RTP Forwarder",
|
||||
"link": "rtp-forwarder",
|
||||
"description": "The rtp-forwarder example demonstrates how to forward your audio/video streams using RTP.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "RTP to WebRTC",
|
||||
"link": "rtp-to-webrtc",
|
||||
"description": "The rtp-to-webrtc example demonstrates how to take RTP packets sent to a Pion process into your browser.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Custom Logger",
|
||||
"link": "#",
|
||||
"description": "Example custom-logger demonstrates how the user can override the logging and process messages instead of printing to stdout. It has no corresponding web page.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Simulcast",
|
||||
"link": "simulcast",
|
||||
"description": "Example simulcast demonstrates how to accept and demux 1 Track that contains 3 Simulcast streams. It then returns the media as 3 independent Tracks back to the sender.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "ICE Restart",
|
||||
"link": "#",
|
||||
"description": "Example ice-restart demonstrates how a WebRTC connection can roam between networks. This example restarts ICE in a loop and prints the new addresses it uses each time.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "ICE Single Port",
|
||||
"link": "#",
|
||||
"description": "Example ice-single-port demonstrates how multiple WebRTC connections can be served from a single port. By default Pion listens on a new port for every PeerConnection. Pion can be configured to use a single port for multiple connections.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "ICE TCP",
|
||||
"link": "#",
|
||||
"description": "Example ice-tcp demonstrates how a WebRTC connection can be made over TCP instead of UDP. By default Pion only does UDP. Pion can be configured to use a TCP port, and this TCP port can be used for many connections.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "Swap Tracks",
|
||||
"link": "swap-tracks",
|
||||
"description": "The swap-tracks example demonstrates deeper usage of the Pion Media API. The server accepts 3 media streams, and then dynamically routes them back as a single stream to the user.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "VNet",
|
||||
"link": "#",
|
||||
"description": "The vnet example demonstrates Pion's network virtualisation library. This example connects two PeerConnections over a virtual network and prints statistics about the data traveling over it.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "rtcp-processing",
|
||||
"link": "rtcp-processing",
|
||||
"description": "The rtcp-processing example demonstrates Pion's RTCP APIs. This allow access to media statistics and control information.",
|
||||
"type": "browser"
|
||||
},
|
||||
{
|
||||
"title": "trickle-ice",
|
||||
"link": "#",
|
||||
"description": "The trickle-ice example demonstrates Pion WebRTC's Trickle ICE APIs.",
|
||||
"type": "browser"
|
||||
}
|
||||
]
|
||||
26
examples/ice-proxy/README.md
Normal file
26
examples/ice-proxy/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# ICE Proxy
|
||||
`ice-proxy` demonstrates Pion WebRTC's capabilities for utilizing a proxy in WebRTC connections.
|
||||
|
||||
This proxy functionality is particularly useful when direct peer-to-peer communication is restricted, such as in environments with strict firewalls. It primarily leverages TURN (Traversal Using Relays around NAT) with TCP connections to enable communication with the outside world.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download ice-proxy
|
||||
The example is self-contained and requires no input.
|
||||
|
||||
```bash
|
||||
go install github.com/pion/webrtc/v4/examples/ice-proxy@latest
|
||||
```
|
||||
|
||||
### Run ice-proxy
|
||||
```bash
|
||||
ice-proxy
|
||||
```
|
||||
|
||||
Upon execution, four distinct entities will be launched:
|
||||
* `TURN Server`: This server facilitates relaying media traffic when direct communication between agents is not possible, simulating a scenario where peers are behind restrictive NATs.
|
||||
* `Proxy HTTP Server`: A straightforward HTTP proxy designed to forward all TCP traffic to a specified target.
|
||||
* `Offering Agent`: In a typical WebRTC setup, this would be a web browser. In this example, it's a simplified Pion client that initiates the WebRTC connection. This agent attempts direct communication with the answering agent.
|
||||
* `Answering Agent`: This typically represents a web server. In this demonstration, it's configured to use the TURN server, simulating a scenario where the agent is not directly reachable. This agent exclusively uses a relay connection via the TURN server, with a proxy acting as an intermediary between the agent and the TURN server.
|
||||
|
||||
|
||||
116
examples/ice-proxy/answer.go
Normal file
116
examples/ice-proxy/answer.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint:cyclop
|
||||
func setupAnsweringAgent() {
|
||||
// Create and start a simple HTTP proxy server.
|
||||
proxyURL := newHTTPProxy()
|
||||
// Create a proxy dialer that will use the created HTTP proxy.
|
||||
proxyDialer := newProxyDialer(proxyURL)
|
||||
|
||||
var settingEngine webrtc.SettingEngine
|
||||
// Set the ICEProxyDialer to use the proxy for TURN+TCP connections.
|
||||
settingEngine.SetICEProxyDialer(proxyDialer)
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
|
||||
|
||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{turnServerURL},
|
||||
Username: turnUsername,
|
||||
Credential: turnPassword,
|
||||
},
|
||||
},
|
||||
// ICETransportPolicyRelay forces the connection to go through a TURN server.
|
||||
// This is required for the proxy to be used.
|
||||
ICETransportPolicy: webrtc.ICETransportPolicyRelay,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Log peer connection and ICE connection state changes.
|
||||
peerConnection.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) {
|
||||
log.Printf("[Answerer] Peer Connection State has changed: %s", pcs.String())
|
||||
})
|
||||
peerConnection.OnICEConnectionStateChange(func(ics webrtc.ICEConnectionState) {
|
||||
log.Printf("[Answerer] ICE Connection State has changed: %s", ics.String())
|
||||
})
|
||||
|
||||
// Register a handler for when a data channel is created by the remote peer.
|
||||
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
icePair, err := d.Transport().Transport().ICETransport().GetSelectedCandidatePair()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Log the chosen ICE candidate pair.
|
||||
log.Printf("[Answerer] New DataChannel %s, ICE pair: (%s)<->(%s)",
|
||||
d.Label(), icePair.Local.String(), icePair.Remote.String())
|
||||
// Register a handler to echo messages back to the sender.
|
||||
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if err := d.SendText(string(msg.Data)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// HTTP handler that accepts an offer, creates an answer,
|
||||
// and sends it back to the offering agent.
|
||||
http.HandleFunc("/sdp", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var sdp webrtc.SessionDescription
|
||||
if err := json.NewDecoder(r.Body).Decode(&sdp); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := peerConnection.SetRemoteDescription(sdp); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
resp, err := json.Marshal(*peerConnection.LocalDescription())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err := rw.Write(resp); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
// Start an HTTP server to handle the SDP exchange from the offering agent.
|
||||
go func() {
|
||||
// The HTTP server is not gracefully shutdown in this example.
|
||||
// nolint:gosec
|
||||
err := http.ListenAndServe("localhost:8080", nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
28
examples/ice-proxy/main.go
Normal file
28
examples/ice-proxy/main.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// ice-proxy demonstrates Pion WebRTC's proxy abilities.
|
||||
package main
|
||||
|
||||
const (
|
||||
turnServerAddr = "localhost:17342"
|
||||
turnServerURL = "turn:" + turnServerAddr + "?transport=tcp"
|
||||
turnUsername = "turn_username"
|
||||
turnPassword = "turn_password"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Setup TURN server.
|
||||
turnServer := newTURNServer()
|
||||
defer turnServer.Close() // nolint:errcheck
|
||||
|
||||
// Setup answering agent with proxy and TURN.
|
||||
setupAnsweringAgent()
|
||||
// Setup offering agent with only direct communication.
|
||||
setupOfferingAgent()
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
99
examples/ice-proxy/offer.go
Normal file
99
examples/ice-proxy/offer.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint:cyclop
|
||||
func setupOfferingAgent() {
|
||||
var settingEngine webrtc.SettingEngine
|
||||
// Allow loopback candidates.
|
||||
settingEngine.SetIncludeLoopbackCandidate(true)
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
|
||||
|
||||
// Create a new RTCPeerConnection.
|
||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Log peer connection and ICE connection state changes.
|
||||
peerConnection.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) {
|
||||
log.Printf("[Offerer] Peer Connection State has changed: %s", pcs.String())
|
||||
})
|
||||
peerConnection.OnICEConnectionStateChange(func(ics webrtc.ICEConnectionState) {
|
||||
log.Printf("[Offerer] ICE Connection State has changed: %s", ics.String())
|
||||
})
|
||||
|
||||
// Create a data channel for measuring round-trip time.
|
||||
dc, err := peerConnection.CreateDataChannel("data-channel", nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
dc.OnOpen(func() {
|
||||
// Send the current time every 3 seconds.
|
||||
for range time.Tick(3 * time.Second) {
|
||||
if sendErr := dc.SendText(time.Now().Format(time.RFC3339Nano)); sendErr != nil {
|
||||
panic(sendErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
// Receive the echoed time from the remote agent and calculate the round-trip time.
|
||||
sendTime, parseErr := time.Parse(time.RFC3339Nano, string(msg.Data))
|
||||
if parseErr != nil {
|
||||
panic(parseErr)
|
||||
}
|
||||
log.Printf("[Offerer] Data channel round-trip time: %s", time.Since(sendTime))
|
||||
})
|
||||
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Create an offer to send to the answering agent.
|
||||
offer, err := peerConnection.CreateOffer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = peerConnection.SetLocalDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE.
|
||||
// We do this because we only can exchange one signaling message.
|
||||
// In a production application you should exchange ICE Candidates via OnICECandidate.
|
||||
<-gatherComplete
|
||||
|
||||
offerJSON, err := json.Marshal(*peerConnection.LocalDescription())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send offer to the answering agent.
|
||||
// nolint:noctx
|
||||
resp, err := http.Post("http://localhost:8080/sdp", "application/json", bytes.NewBuffer(offerJSON))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer resp.Body.Close() // nolint:errcheck
|
||||
|
||||
// Receive answer and set remote description.
|
||||
var answer webrtc.SessionDescription
|
||||
if err = json.NewDecoder(resp.Body).Decode(&answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = peerConnection.SetRemoteDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
124
examples/ice-proxy/proxy.go
Normal file
124
examples/ice-proxy/proxy.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
var _ proxy.Dialer = &proxyDialer{}
|
||||
|
||||
type proxyDialer struct {
|
||||
proxyAddr string
|
||||
}
|
||||
|
||||
func newProxyDialer(u *url.URL) proxy.Dialer {
|
||||
if u.Scheme != "http" {
|
||||
panic("unsupported proxy scheme")
|
||||
}
|
||||
|
||||
return &proxyDialer{
|
||||
proxyAddr: u.Host,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *proxyDialer) Dial(network, addr string) (net.Conn, error) {
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
panic("unsupported proxy network type")
|
||||
}
|
||||
|
||||
conn, err := net.Dial(network, d.proxyAddr) // nolint: noctx
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a CONNECT request to the proxy with target address.
|
||||
req := &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{Host: addr},
|
||||
Header: http.Header{
|
||||
"Proxy-Connection": []string{"Keep-Alive"},
|
||||
},
|
||||
}
|
||||
|
||||
err = req.Write(conn)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Printf("close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
panic("unexpected proxy status code: " + resp.Status)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func newHTTPProxy() *url.URL {
|
||||
listener, err := net.Listen("tcp", "localhost:0") // nolint: noctx
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go proxyHandleConn(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return &url.URL{
|
||||
Scheme: "http",
|
||||
Host: fmt.Sprintf("localhost:%d", listener.Addr().(*net.TCPAddr).Port), // nolint:forcetypeassert
|
||||
}
|
||||
}
|
||||
|
||||
func proxyHandleConn(clientConn net.Conn) {
|
||||
// Read the request from the client
|
||||
req, err := http.ReadRequest(bufio.NewReader(clientConn))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if req.Method != http.MethodConnect {
|
||||
panic("unexpected request method: " + req.Method)
|
||||
}
|
||||
|
||||
// Establish a connection to the target server
|
||||
targetConn, err := net.Dial("tcp", req.URL.Host) // nolint:noctx,gosec
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Answer to the client with a 200 OK response
|
||||
if _, err := clientConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Copy data between client and target
|
||||
go io.Copy(clientConn, targetConn) // nolint: errcheck
|
||||
go io.Copy(targetConn, clientConn) // nolint: errcheck
|
||||
}
|
||||
39
examples/ice-proxy/turn.go
Normal file
39
examples/ice-proxy/turn.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/pion/turn/v5"
|
||||
)
|
||||
|
||||
func newTURNServer() *turn.Server {
|
||||
tcpListener, err := net.Listen("tcp4", turnServerAddr) // nolint: noctx
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
server, err := turn.NewServer(turn.ServerConfig{
|
||||
AuthHandler: func(ra *turn.RequestAttributes) (string, []byte, bool) {
|
||||
// Accept any request with provided username and password.
|
||||
return turnUsername, turn.GenerateAuthKey(turnUsername, ra.Realm, turnPassword), true
|
||||
},
|
||||
ListenerConfigs: []turn.ListenerConfig{
|
||||
{
|
||||
Listener: tcpListener,
|
||||
RelayAddressGenerator: &turn.RelayAddressGeneratorNone{
|
||||
Address: "localhost",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
26
examples/ice-restart/README.md
Normal file
26
examples/ice-restart/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# ice-restart
|
||||
ice-restart demonstrates Pion WebRTC's ICE Restart abilities.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download ice-restart
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/ice-restart
|
||||
```
|
||||
|
||||
### Run ice-restart
|
||||
Execute `go run *.go`
|
||||
|
||||
### Open the Web UI
|
||||
Open [http://localhost:8080](http://localhost:8080). This will automatically start a PeerConnection. This page will now prints stats about the PeerConnection
|
||||
and allow you to do an ICE Restart at anytime.
|
||||
|
||||
* `ICE Restart` is the button that causes a new offer to be made with `iceRestart: true`.
|
||||
* `ICE Connection States` will contain all the connection states the PeerConnection moves through.
|
||||
* `ICE Selected Pairs` will print the selected pair every 3 seconds. Note how the uFrag/uPwd/Port change everytime you start the Restart process.
|
||||
* `Inbound DataChannel Messages` containing the current time sent by the Pion process every 3 seconds.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
86
examples/ice-restart/index.html
Normal file
86
examples/ice-restart/index.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<title>ice-restart</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button onclick="window.doSignaling(true)"> ICE Restart </button><br />
|
||||
|
||||
|
||||
<h3> ICE Connection States </h3>
|
||||
<div id="iceConnectionStates"></div> <br />
|
||||
|
||||
<h3> ICE Selected Pairs </h3>
|
||||
<div id="iceSelectedPairs"></div> <br />
|
||||
|
||||
<h3> Inbound DataChannel Messages </h3>
|
||||
<div id="inboundDataChannelMessages"></div>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
let pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}
|
||||
]
|
||||
})
|
||||
let dc = pc.createDataChannel('data')
|
||||
|
||||
dc.onopen = () => {
|
||||
setInterval(function() {
|
||||
let el = document.createElement('template')
|
||||
let selectedPair = pc.sctp.transport.iceTransport.getSelectedCandidatePair()
|
||||
|
||||
el.innerHTML = `<div>
|
||||
<ul>
|
||||
<li> <i> Local</i> - ${selectedPair.local.candidate}</li>
|
||||
<li> <i> Remote</i> - ${selectedPair.remote.candidate} </li>
|
||||
</ul>
|
||||
</div>`
|
||||
|
||||
document.getElementById('iceSelectedPairs').appendChild(el.content.firstChild);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
dc.onmessage = event => {
|
||||
let el = document.createElement('p')
|
||||
el.appendChild(document.createTextNode(event.data))
|
||||
|
||||
document.getElementById('inboundDataChannelMessages').appendChild(el);
|
||||
}
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
let el = document.createElement('p')
|
||||
el.appendChild(document.createTextNode(pc.iceConnectionState))
|
||||
|
||||
document.getElementById('iceConnectionStates').appendChild(el);
|
||||
}
|
||||
|
||||
|
||||
window.doSignaling = iceRestart => {
|
||||
pc.createOffer({iceRestart})
|
||||
.then(offer => {
|
||||
pc.setLocalDescription(offer)
|
||||
|
||||
return fetch(`/doSignaling`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(offer)
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => pc.setRemoteDescription(res))
|
||||
.catch(alert)
|
||||
}
|
||||
|
||||
window.doSignaling(false)
|
||||
</script>
|
||||
</html>
|
||||
87
examples/ice-restart/main.go
Normal file
87
examples/ice-restart/main.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// ice-restart demonstrates Pion WebRTC's ICE Restart abilities.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
var peerConnection *webrtc.PeerConnection //nolint
|
||||
|
||||
// nolint: cyclop
|
||||
func doSignaling(res http.ResponseWriter, req *http.Request) {
|
||||
var err error
|
||||
|
||||
if peerConnection == nil {
|
||||
if peerConnection, err = webrtc.NewPeerConnection(webrtc.Configuration{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
|
||||
})
|
||||
|
||||
// Send the current time via a DataChannel to the remote peer every 3 seconds
|
||||
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
d.OnOpen(func() {
|
||||
for range time.Tick(time.Second * 3) {
|
||||
if err = d.SendText(time.Now().String()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
var offer webrtc.SessionDescription
|
||||
if err = json.NewDecoder(req.Body).Decode(&offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
response, err := json.Marshal(*peerConnection.LocalDescription())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
res.Header().Set("Content-Type", "application/json")
|
||||
if _, err := res.Write(response); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.Handle("/", http.FileServer(http.Dir(".")))
|
||||
http.HandleFunc("/doSignaling", doSignaling)
|
||||
|
||||
fmt.Println("Open http://localhost:8080 to access this demo")
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
26
examples/ice-single-port/README.md
Normal file
26
examples/ice-single-port/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# ice-single-port
|
||||
ice-single-port demonstrates Pion WebRTC's ability to serve many PeerConnections on a single port.
|
||||
|
||||
Pion WebRTC has no global state, so by default ports can't be shared between two PeerConnections.
|
||||
Using the SettingEngine, a developer can manually share state between many PeerConnections to allow
|
||||
multiple PeerConnections to use the same port.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download ice-single-port
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/ice-single-port
|
||||
```
|
||||
|
||||
### Run ice-single-port
|
||||
Execute `go run *.go`
|
||||
|
||||
### Open the Web UI
|
||||
Open [http://localhost:8080](http://localhost:8080). This will automatically open 10 PeerConnections. This page will print
|
||||
a Local/Remote line for each PeerConnection. Note that all 10 PeerConnections have different ports for their Local port.
|
||||
However for the remote they all will be using port 8443.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool.
|
||||
56
examples/ice-single-port/index.html
Normal file
56
examples/ice-single-port/index.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<title>ice-single-port</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h3> ICE Selected Pairs </h3>
|
||||
<div id="iceSelectedPairs"></div> <br />
|
||||
</body>
|
||||
|
||||
<script>
|
||||
let createPeerConnection = () => {
|
||||
let pc = new RTCPeerConnection()
|
||||
let dc = pc.createDataChannel('data')
|
||||
|
||||
dc.onopen = () => {
|
||||
let el = document.createElement('template')
|
||||
let selectedPair = pc.sctp.transport.iceTransport.getSelectedCandidatePair()
|
||||
|
||||
el.innerHTML = `<div>
|
||||
<ul>
|
||||
<li> <i> Local</i> - ${selectedPair.local.candidate}</li>
|
||||
<li> <i> Remote</i> - ${selectedPair.remote.candidate} </li>
|
||||
</ul>
|
||||
</div>`
|
||||
|
||||
document.getElementById('iceSelectedPairs').appendChild(el.content.firstChild);
|
||||
}
|
||||
|
||||
pc.createOffer()
|
||||
.then(offer => {
|
||||
pc.setLocalDescription(offer)
|
||||
|
||||
return fetch(`/doSignaling`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(offer)
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => pc.setRemoteDescription(res))
|
||||
.catch(alert)
|
||||
}
|
||||
|
||||
for (i = 0; i < 10; i++) {
|
||||
createPeerConnection()
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
104
examples/ice-single-port/main.go
Normal file
104
examples/ice-single-port/main.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// ice-single-port demonstrates Pion WebRTC's ability to serve many PeerConnections on a single port.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
var api *webrtc.API //nolint
|
||||
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
func doSignaling(res http.ResponseWriter, req *http.Request) {
|
||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
|
||||
})
|
||||
|
||||
// Send the current time via a DataChannel to the remote peer every 3 seconds
|
||||
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
d.OnOpen(func() {
|
||||
for range time.Tick(time.Second * 3) {
|
||||
if err = d.SendText(time.Now().String()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var offer webrtc.SessionDescription
|
||||
if err = json.NewDecoder(req.Body).Decode(&offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
response, err := json.Marshal(*peerConnection.LocalDescription())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
res.Header().Set("Content-Type", "application/json")
|
||||
if _, err := res.Write(response); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Create a SettingEngine, this allows non-standard WebRTC behavior
|
||||
settingEngine := webrtc.SettingEngine{}
|
||||
|
||||
// Configure our SettingEngine to use our UDPMux. By default a PeerConnection has
|
||||
// no global state. The API+SettingEngine allows the user to share state between them.
|
||||
// In this case we are sharing our listening port across many.
|
||||
// Listen on UDP Port 8443, will be used for all WebRTC traffic
|
||||
mux, err := ice.NewMultiUDPMuxFromPort(8443)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("Listening for WebRTC traffic at %d\n", 8443)
|
||||
settingEngine.SetICEUDPMux(mux)
|
||||
|
||||
// Create a new API using our SettingEngine
|
||||
api = webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
|
||||
|
||||
http.Handle("/", http.FileServer(http.Dir(".")))
|
||||
http.HandleFunc("/doSignaling", doSignaling)
|
||||
|
||||
fmt.Println("Open http://localhost:8080 to access this demo")
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
20
examples/ice-tcp/README.md
Normal file
20
examples/ice-tcp/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# ice-tcp
|
||||
ice-tcp demonstrates Pion WebRTC's ICE TCP abilities.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download ice-tcp
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/ice-tcp
|
||||
```
|
||||
|
||||
### Run ice-tcp
|
||||
Execute `go run *.go`
|
||||
|
||||
### Open the Web UI
|
||||
Open [http://localhost:8080](http://localhost:8080). This will automatically start a PeerConnection. This page will now prints stats about the PeerConnection. The UDP candidates will be filtered out from the SDP.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
57
examples/ice-tcp/index.html
Normal file
57
examples/ice-tcp/index.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<title>ice-tcp</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>ICE TCP</h1>
|
||||
|
||||
<h3> ICE Connection States </h3>
|
||||
<div id="iceConnectionStates"></div> <br />
|
||||
|
||||
<h3> Inbound DataChannel Messages </h3>
|
||||
<div id="inboundDataChannelMessages"></div>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
let pc = new RTCPeerConnection()
|
||||
let dc = pc.createDataChannel('data')
|
||||
|
||||
dc.onmessage = event => {
|
||||
let el = document.createElement('p')
|
||||
el.appendChild(document.createTextNode(event.data))
|
||||
|
||||
document.getElementById('inboundDataChannelMessages').appendChild(el);
|
||||
}
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
let el = document.createElement('p')
|
||||
el.appendChild(document.createTextNode(pc.iceConnectionState))
|
||||
|
||||
document.getElementById('iceConnectionStates').appendChild(el);
|
||||
}
|
||||
|
||||
pc.createOffer()
|
||||
.then(offer => {
|
||||
pc.setLocalDescription(offer)
|
||||
|
||||
return fetch(`/doSignaling`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(offer)
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
pc.setRemoteDescription(res)
|
||||
})
|
||||
.catch(alert)
|
||||
</script>
|
||||
</html>
|
||||
115
examples/ice-tcp/main.go
Normal file
115
examples/ice-tcp/main.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// ice-tcp demonstrates Pion WebRTC's ICE TCP abilities.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
var api *webrtc.API //nolint
|
||||
|
||||
func doSignaling(res http.ResponseWriter, req *http.Request) { //nolint:cyclop
|
||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
|
||||
})
|
||||
|
||||
// Send the current time via a DataChannel to the remote peer every 3 seconds
|
||||
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
d.OnOpen(func() {
|
||||
for range time.Tick(time.Second * 3) {
|
||||
if err = d.SendText(time.Now().String()); err != nil {
|
||||
if errors.Is(err, io.ErrClosedPipe) {
|
||||
return
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var offer webrtc.SessionDescription
|
||||
if err = json.NewDecoder(req.Body).Decode(&offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
response, err := json.Marshal(*peerConnection.LocalDescription())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
res.Header().Set("Content-Type", "application/json")
|
||||
if _, err := res.Write(response); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:cyclop
|
||||
func main() {
|
||||
settingEngine := webrtc.SettingEngine{}
|
||||
|
||||
// Enable support only for TCP ICE candidates.
|
||||
settingEngine.SetNetworkTypes([]webrtc.NetworkType{
|
||||
webrtc.NetworkTypeTCP4,
|
||||
webrtc.NetworkTypeTCP6,
|
||||
})
|
||||
|
||||
tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{
|
||||
IP: net.IP{0, 0, 0, 0},
|
||||
Port: 8443,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Listening for ICE TCP at %s\n", tcpListener.Addr())
|
||||
|
||||
tcpMux := webrtc.NewICETCPMux(nil, tcpListener, 8)
|
||||
settingEngine.SetICETCPMux(tcpMux)
|
||||
|
||||
api = webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
|
||||
|
||||
http.Handle("/", http.FileServer(http.Dir(".")))
|
||||
http.HandleFunc("/doSignaling", doSignaling)
|
||||
|
||||
fmt.Println("Open http://localhost:8080 to access this demo")
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
70
examples/index.html
Normal file
70
examples/index.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<link href="https://fonts.googleapis.com/css?family=Quicksand:400,500,700" rel="stylesheet">
|
||||
<title>WebRTC examples! | Pion</title>
|
||||
</head>
|
||||
<style>
|
||||
body {
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 400;
|
||||
margin: 4em 10%;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
|
||||
transition: 0.3s;
|
||||
margin: 20px auto;
|
||||
flex-grow: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.content {
|
||||
line-height: 1.2em;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
color:#ffffff;
|
||||
background-image: linear-gradient(225deg, #eb6562 0%, #E53935 100%);
|
||||
padding: 6px 16px 3px 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<body>
|
||||
<h1>Pion WebRTC examples</h1>
|
||||
<div class="container">
|
||||
{{range .}}
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h3>{{ .Title }}</h3>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>{{ .Description }}</p>
|
||||
{{ if .IsJS}}<p><a href="/example/js/{{ .Link }}/">Run JavaScript</a></p>{{ end }}
|
||||
{{ if .IsWASM}}<p><a href="/example/wasm/{{ .Link }}/">Run WASM</a></p>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<li><strong>No examples found!</strong></li>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
41
examples/insertable-streams/README.md
Normal file
41
examples/insertable-streams/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# insertable-streams
|
||||
insertable-streams demonstrates how to use insertable streams with Pion.
|
||||
This example modifies the video with a single-byte XOR cipher before sending, and then
|
||||
decrypts in Javascript.
|
||||
|
||||
insertable-streams allows the browser to process encoded video. You could implement
|
||||
E2E encryption, add metadata or insert a completely different video feed!
|
||||
|
||||
## Instructions
|
||||
### Create IVF named `output.ivf` that contains a VP8 track
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -g 30 output.ivf
|
||||
```
|
||||
|
||||
### Download insertable-streams
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/insertable-streams@latest
|
||||
```
|
||||
|
||||
### Open insertable-streams example page
|
||||
[jsfiddle.net](https://jsfiddle.net/t5xoaryc/) you should see two text-areas and a 'Start Session' button. You will also have a 'Decrypt' checkbox.
|
||||
When unchecked the browser will not decrypt the incoming video stream, so it will stop playing or display certificates.
|
||||
|
||||
### Run insertable-streams with your browsers SessionDescription as stdin
|
||||
The `output.ivf` you created should be in the same directory as `insertable-streams`. In the jsfiddle the top textarea is your browser, copy that and:
|
||||
|
||||
#### Linux/macOS
|
||||
Run `echo $BROWSER_SDP | insertable-streams`
|
||||
#### Windows
|
||||
1. Paste the SessionDescription into a file.
|
||||
1. Run `insertable-streams < my_file`
|
||||
|
||||
### Input insertable-streams's SessionDescription into your browser
|
||||
Copy the text that `insertable-streams` just emitted and copy into second text area
|
||||
|
||||
### Hit 'Start Session' in jsfiddle, enjoy your video!
|
||||
A video should start playing in your browser above the input boxes. `insertable-streams` will exit when the file reaches the end.
|
||||
|
||||
To stop decrypting the stream uncheck the box and the video will not be viewable.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
8
examples/insertable-streams/jsfiddle/demo.css
Normal file
8
examples/insertable-streams/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/insertable-streams/jsfiddle/demo.details
Normal file
8
examples/insertable-streams/jsfiddle/demo.details
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: play-from-disk
|
||||
description: play-from-disk demonstrates how to send video to your browser from a file saved to disk.
|
||||
authors:
|
||||
- Sean DuBois
|
||||
22
examples/insertable-streams/jsfiddle/demo.html
Normal file
22
examples/insertable-streams/jsfiddle/demo.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<div id="no-support-banner" style="background-color: red">
|
||||
<h1> Browser does not support insertable streams </h1>
|
||||
</div>
|
||||
|
||||
Browser base64 Session Description<br />
|
||||
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
|
||||
|
||||
Golang base64 Session Description<br />
|
||||
<textarea id="remoteSessionDescription"> </textarea> <br/>
|
||||
<button onclick="window.startSession()"> Start Session </button> Decrypt Video <input type="checkbox" checked="checked" onclick="window.toggleDecryption()"/> <br />
|
||||
|
||||
<br />
|
||||
|
||||
Video<br />
|
||||
<video id="remote-video" playsinline autoplay controls style="width: 640; height: 480"></video> <br />
|
||||
|
||||
Logs<br />
|
||||
<div id="div"></div>
|
||||
98
examples/insertable-streams/jsfiddle/demo.js
Normal file
98
examples/insertable-streams/jsfiddle/demo.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// cipherKey that video is encrypted with
|
||||
const cipherKey = 0xAA
|
||||
|
||||
const pc = new RTCPeerConnection({ encodedInsertableStreams: true, forceEncodedVideoInsertableStreams: true })
|
||||
const log = msg => {
|
||||
document.getElementById('div').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
// Offer to receive 1 video
|
||||
const transceiver = pc.addTransceiver('video')
|
||||
|
||||
// The API has seen two iterations, support both
|
||||
// In the future this will just be `createEncodedStreams`
|
||||
const receiverStreams = getInsertableStream(transceiver)
|
||||
|
||||
// boolean controlled by checkbox to enable/disable encryption
|
||||
let applyDecryption = true
|
||||
window.toggleDecryption = () => {
|
||||
applyDecryption = !applyDecryption
|
||||
}
|
||||
|
||||
// Loop that is called for each video frame
|
||||
const reader = receiverStreams.readable.getReader()
|
||||
const writer = receiverStreams.writable.getWriter()
|
||||
reader.read().then(function processVideo ({ done, value }) {
|
||||
const decrypted = new DataView(value.data)
|
||||
|
||||
if (applyDecryption) {
|
||||
for (let i = 0; i < decrypted.buffer.byteLength; i++) {
|
||||
decrypted.setInt8(i, decrypted.getInt8(i) ^ cipherKey)
|
||||
}
|
||||
}
|
||||
|
||||
value.data = decrypted.buffer
|
||||
writer.write(value)
|
||||
return reader.read().then(processVideo)
|
||||
})
|
||||
|
||||
// Fire when remote video arrives
|
||||
pc.ontrack = function (event) {
|
||||
document.getElementById('remote-video').srcObject = event.streams[0]
|
||||
document.getElementById('remote-video').style = ''
|
||||
}
|
||||
|
||||
// Populate SDP field when finished gathering
|
||||
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
|
||||
pc.onicecandidate = event => {
|
||||
if (event.candidate === null) {
|
||||
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
|
||||
}
|
||||
}
|
||||
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
|
||||
|
||||
window.startSession = () => {
|
||||
const sd = document.getElementById('remoteSessionDescription').value
|
||||
if (sd === '') {
|
||||
return alert('Session Description must not be empty')
|
||||
}
|
||||
|
||||
try {
|
||||
pc.setRemoteDescription(JSON.parse(atob(sd)))
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}
|
||||
|
||||
// DOM code to show banner if insertable streams not supported
|
||||
let insertableStreamsSupported = true
|
||||
const updateSupportBanner = () => {
|
||||
const el = document.getElementById('no-support-banner')
|
||||
if (insertableStreamsSupported && el) {
|
||||
el.style = 'display: none'
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', updateSupportBanner)
|
||||
|
||||
// Shim to support both versions of API
|
||||
function getInsertableStream (transceiver) {
|
||||
let insertableStreams = null
|
||||
if (transceiver.receiver.createEncodedVideoStreams) {
|
||||
insertableStreams = transceiver.receiver.createEncodedVideoStreams()
|
||||
} else if (transceiver.receiver.createEncodedStreams) {
|
||||
insertableStreams = transceiver.receiver.createEncodedStreams()
|
||||
}
|
||||
|
||||
if (!insertableStreams) {
|
||||
insertableStreamsSupported = false
|
||||
updateSupportBanner()
|
||||
throw new Error('Insertable Streams are not supported')
|
||||
}
|
||||
|
||||
return insertableStreams
|
||||
}
|
||||
224
examples/insertable-streams/main.go
Normal file
224
examples/insertable-streams/main.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// insertable-streams demonstrates how to use insertable streams with Pion
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
)
|
||||
|
||||
const cipherKey = 0xAA
|
||||
|
||||
// nolint:gocognit, cyclop
|
||||
func main() {
|
||||
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create a video track
|
||||
videoTrack, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "pion",
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rtpSender, err := peerConnection.AddTrack(videoTrack)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
// Open a IVF file and start reading using our IVFReader
|
||||
file, ivfErr := os.Open("output.ivf")
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
ivf, header, ivfErr := ivfreader.NewWith(file)
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
// Wait for connection established
|
||||
<-iceConnectedCtx.Done()
|
||||
|
||||
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
|
||||
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
|
||||
//
|
||||
// It is important to use a time.Ticker instead of time.Sleep because
|
||||
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
|
||||
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
|
||||
ticker := time.NewTicker(
|
||||
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
|
||||
)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
frame, _, ivfErr := ivf.ParseNextFrame()
|
||||
if errors.Is(ivfErr, io.EOF) {
|
||||
fmt.Printf("All frames parsed and sent")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
// Encrypt video using XOR Cipher
|
||||
for i := range frame {
|
||||
frame[i] ^= cipherKey
|
||||
}
|
||||
|
||||
if ivfErr = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("Connection State has changed %s \n", connectionState.String())
|
||||
if connectionState == webrtc.ICEConnectionStateConnected {
|
||||
iceConnectedCtxCancel()
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for the offer to be pasted
|
||||
offer := webrtc.SessionDescription{}
|
||||
decode(readUntilNewline(), &offer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
if err = peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the answer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
46
examples/ortc-media/README.md
Normal file
46
examples/ortc-media/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# ortc-media
|
||||
ortc demonstrates Pion WebRTC's [ORTC](https://ortc.org/) capabilities. Instead of using the Session Description Protocol
|
||||
to configure and communicate ORTC provides APIs. Users then can implement signaling with whatever protocol they wish.
|
||||
ORTC can then be used to implement WebRTC. A ORTC implementation can parse/emit Session Description and act as a WebRTC
|
||||
implementation.
|
||||
|
||||
In this example we have defined a simple JSON based signaling protocol.
|
||||
|
||||
## Instructions
|
||||
### Create IVF named `output.ivf` that contains a VP8/VP9/AV1 track
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -g 30 -b:v 2M output.ivf
|
||||
```
|
||||
|
||||
**Note**: In the `ffmpeg` command which produces the .ivf file, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
|
||||
|
||||
|
||||
### Download ortc-media
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/ortc-media@latest
|
||||
```
|
||||
|
||||
### Run first client as offerer
|
||||
`ortc-media -offer` this will emit a base64 message. Copy this message to your clipboard.
|
||||
|
||||
## Run the second client as answerer
|
||||
Run the second client. This should be launched with the message you copied in the previous step as stdin.
|
||||
|
||||
`echo BASE64_MESSAGE_YOU_COPIED | ortc-media`
|
||||
|
||||
This will emit another base64 message. Copy this new message.
|
||||
|
||||
## Send base64 message to first client via CURL
|
||||
|
||||
* Run `curl localhost:8080 -d "BASE64_MESSAGE_YOU_COPIED"`. `BASE64_MESSAGE_YOU_COPIED` is the value you copied in the last step.
|
||||
|
||||
### Enjoy
|
||||
The client that accepts media will print when it gets the first media packet. The SSRC will be different every run.
|
||||
|
||||
```
|
||||
Got RTP Packet with SSRC 3097857772
|
||||
```
|
||||
|
||||
Media packets will continue to flow until the end of the file has been reached.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
314
examples/ortc-media/main.go
Normal file
314
examples/ortc-media/main.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// ortc demonstrates Pion WebRTC's ORTC capabilities.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
)
|
||||
|
||||
const (
|
||||
videoFileName = "output.ivf"
|
||||
)
|
||||
|
||||
// nolint:cyclop
|
||||
func main() {
|
||||
isOffer := flag.Bool("offer", false, "Act as the offerer if set")
|
||||
port := flag.Int("port", 8080, "http server port")
|
||||
flag.Parse()
|
||||
|
||||
// Everything below is the Pion WebRTC (ORTC) API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare ICE gathering options
|
||||
iceOptions := webrtc.ICEGatherOptions{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Use default Codecs
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create an API object
|
||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
|
||||
|
||||
// Create the ICE gatherer
|
||||
gatherer, err := api.NewICEGatherer(iceOptions)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Construct the ICE transport
|
||||
ice := api.NewICETransport(gatherer)
|
||||
|
||||
// Construct the DTLS transport
|
||||
dtls, err := api.NewDTLSTransport(ice, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a RTPSender or RTPReceiver
|
||||
var (
|
||||
rtpReceiver *webrtc.RTPReceiver
|
||||
rtpSendParameters webrtc.RTPSendParameters
|
||||
)
|
||||
|
||||
if *isOffer { //nolint:nestif
|
||||
// Open the video file
|
||||
file, fileErr := os.Open(videoFileName)
|
||||
if fileErr != nil {
|
||||
panic(fileErr)
|
||||
}
|
||||
|
||||
// Read the header of the video file
|
||||
ivf, header, fileErr := ivfreader.NewWith(file)
|
||||
if fileErr != nil {
|
||||
panic(fileErr)
|
||||
}
|
||||
|
||||
trackLocal := fourCCToTrack(header.FourCC)
|
||||
|
||||
// Create RTPSender to send our video file
|
||||
rtpSender, fileErr := api.NewRTPSender(trackLocal, dtls)
|
||||
if fileErr != nil {
|
||||
panic(fileErr)
|
||||
}
|
||||
|
||||
rtpSendParameters = rtpSender.GetParameters()
|
||||
|
||||
if fileErr = rtpSender.Send(rtpSendParameters); fileErr != nil {
|
||||
panic(fileErr)
|
||||
}
|
||||
|
||||
go writeFileToTrack(ivf, header, trackLocal)
|
||||
} else {
|
||||
if rtpReceiver, err = api.NewRTPReceiver(webrtc.RTPCodecTypeVideo, dtls); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
gatherFinished := make(chan struct{})
|
||||
gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
close(gatherFinished)
|
||||
}
|
||||
})
|
||||
|
||||
// Gather candidates
|
||||
if err = gatherer.Gather(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
<-gatherFinished
|
||||
|
||||
iceCandidates, err := gatherer.GetLocalCandidates()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
iceParams, err := gatherer.GetLocalParameters()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dtlsParams, err := dtls.GetLocalParameters()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
signal := Signal{
|
||||
ICECandidates: iceCandidates,
|
||||
ICEParameters: iceParams,
|
||||
DTLSParameters: dtlsParams,
|
||||
RTPSendParameters: rtpSendParameters,
|
||||
}
|
||||
|
||||
iceRole := webrtc.ICERoleControlled
|
||||
|
||||
// Exchange the information
|
||||
fmt.Println(encode(&signal))
|
||||
remoteSignal := Signal{}
|
||||
|
||||
if *isOffer {
|
||||
signalingChan := httpSDPServer(*port)
|
||||
decode(<-signalingChan, &remoteSignal)
|
||||
|
||||
iceRole = webrtc.ICERoleControlling
|
||||
} else {
|
||||
decode(readUntilNewline(), &remoteSignal)
|
||||
}
|
||||
|
||||
if err = ice.SetRemoteCandidates(remoteSignal.ICECandidates); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start the ICE transport
|
||||
if err = ice.Start(nil, remoteSignal.ICEParameters, &iceRole); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start the DTLS transport
|
||||
if err = dtls.Start(remoteSignal.DTLSParameters); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if !*isOffer {
|
||||
if err = rtpReceiver.Receive(webrtc.RTPReceiveParameters{
|
||||
Encodings: []webrtc.RTPDecodingParameters{
|
||||
{
|
||||
RTPCodingParameters: remoteSignal.RTPSendParameters.Encodings[0].RTPCodingParameters,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
remoteTrack := rtpReceiver.Track()
|
||||
pkt, _, err := remoteTrack.ReadRTP()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Got RTP Packet with SSRC %d \n", pkt.SSRC)
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
// Given a FourCC value return a Track.
|
||||
func fourCCToTrack(fourCC string) *webrtc.TrackLocalStaticSample {
|
||||
// Determine video codec
|
||||
var trackCodec string
|
||||
switch fourCC {
|
||||
case "AV01":
|
||||
trackCodec = webrtc.MimeTypeAV1
|
||||
case "VP90":
|
||||
trackCodec = webrtc.MimeTypeVP9
|
||||
case "VP80":
|
||||
trackCodec = webrtc.MimeTypeVP8
|
||||
default:
|
||||
panic(fmt.Sprintf("Unable to handle FourCC %s", fourCC))
|
||||
}
|
||||
|
||||
// Create a video Track with the codec of the file
|
||||
trackLocal, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return trackLocal
|
||||
}
|
||||
|
||||
// Write a file to Track.
|
||||
func writeFileToTrack(ivf *ivfreader.IVFReader, header *ivfreader.IVFFileHeader, track *webrtc.TrackLocalStaticSample) {
|
||||
ticker := time.NewTicker(
|
||||
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
|
||||
)
|
||||
defer ticker.Stop()
|
||||
for ; true; <-ticker.C {
|
||||
frame, _, err := ivf.ParseNextFrame()
|
||||
if errors.Is(err, io.EOF) {
|
||||
fmt.Printf("All video frames parsed and sent")
|
||||
os.Exit(0) //nolint: gocritic
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = track.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal is used to exchange signaling info.
|
||||
// This is not part of the ORTC spec. You are free
|
||||
// to exchange this information any way you want.
|
||||
type Signal struct {
|
||||
ICECandidates []webrtc.ICECandidate `json:"iceCandidates"`
|
||||
ICEParameters webrtc.ICEParameters `json:"iceParameters"`
|
||||
DTLSParameters webrtc.DTLSParameters `json:"dtlsParameters"`
|
||||
RTPSendParameters webrtc.RTPSendParameters `json:"rtpSendParameters"`
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *Signal) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *Signal) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// httpSDPServer starts a HTTP Server that consumes SDPs.
|
||||
func httpSDPServer(port int) chan string {
|
||||
sdpChan := make(chan string)
|
||||
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
fmt.Fprintf(res, "done") //nolint: errcheck
|
||||
sdpChan <- string(body)
|
||||
})
|
||||
|
||||
go func() {
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
|
||||
}()
|
||||
|
||||
return sdpChan
|
||||
}
|
||||
34
examples/ortc/README.md
Normal file
34
examples/ortc/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# ortc
|
||||
ortc demonstrates Pion WebRTC's [ORTC](https://ortc.org/) capabilities. Instead of using the Session Description Protocol
|
||||
to configure and communicate ORTC provides APIs. Users then can implement signaling with whatever protocol they wish.
|
||||
ORTC can then be used to implement WebRTC. A ORTC implementation can parse/emit Session Description and act as a WebRTC
|
||||
implementation.
|
||||
|
||||
In this example we have defined a simple JSON based signaling protocol.
|
||||
|
||||
## Instructions
|
||||
### Download ortc
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/ortc@latest
|
||||
```
|
||||
|
||||
### Run first client as offerer
|
||||
`ortc -offer` this will emit a base64 message. Copy this message to your clipboard.
|
||||
|
||||
## Run the second client as answerer
|
||||
Run the second client. This should be launched with the message you copied in the previous step as stdin.
|
||||
|
||||
`echo $BASE64_MESSAGE_YOU_COPIED | ortc`
|
||||
|
||||
This will emit another base64 message. Copy this new message.
|
||||
|
||||
## Send base64 message to first client via CURL
|
||||
|
||||
* Run `curl localhost:8080 -d "BASE64_MESSAGE_YOU_COPIED"`. `BASE64_MESSAGE_YOU_COPIED` is the value you copied in the last step.
|
||||
|
||||
### Enjoy
|
||||
If everything worked you will see `Data channel 'Foo'-'' open.` in each terminal.
|
||||
|
||||
Each client will send random messages every 5 seconds that will appear in the terminal
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
263
examples/ortc/main.go
Normal file
263
examples/ortc/main.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// ortc demonstrates Pion WebRTC's ORTC capabilities.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint:cyclop
|
||||
func main() {
|
||||
isOffer := flag.Bool("offer", false, "Act as the offerer if set")
|
||||
port := flag.Int("port", 8080, "http server port")
|
||||
flag.Parse()
|
||||
|
||||
// Everything below is the Pion WebRTC (ORTC) API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare ICE gathering options
|
||||
iceOptions := webrtc.ICEGatherOptions{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
},
|
||||
}
|
||||
|
||||
// Create an API object
|
||||
api := webrtc.NewAPI()
|
||||
|
||||
// Create the ICE gatherer
|
||||
gatherer, err := api.NewICEGatherer(iceOptions)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Construct the ICE transport
|
||||
ice := api.NewICETransport(gatherer)
|
||||
|
||||
// Construct the DTLS transport
|
||||
dtls, err := api.NewDTLSTransport(ice, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Construct the SCTP transport
|
||||
sctp := api.NewSCTPTransport(dtls)
|
||||
|
||||
// Handle incoming data channels
|
||||
sctp.OnDataChannel(func(channel *webrtc.DataChannel) {
|
||||
fmt.Printf("New DataChannel %s %d\n", channel.Label(), channel.ID())
|
||||
|
||||
// Register the handlers
|
||||
channel.OnOpen(handleOnOpen(channel))
|
||||
channel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
fmt.Printf("Message from DataChannel '%s': '%s'\n", channel.Label(), string(msg.Data))
|
||||
})
|
||||
})
|
||||
|
||||
gatherFinished := make(chan struct{})
|
||||
gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
close(gatherFinished)
|
||||
}
|
||||
})
|
||||
|
||||
// Gather candidates
|
||||
if err = gatherer.Gather(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
<-gatherFinished
|
||||
|
||||
iceCandidates, err := gatherer.GetLocalCandidates()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
iceParams, err := gatherer.GetLocalParameters()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dtlsParams, err := dtls.GetLocalParameters()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
sctpCapabilities := sctp.GetCapabilities()
|
||||
|
||||
s := Signal{
|
||||
ICECandidates: iceCandidates,
|
||||
ICEParameters: iceParams,
|
||||
DTLSParameters: dtlsParams,
|
||||
SCTPCapabilities: sctpCapabilities,
|
||||
}
|
||||
|
||||
iceRole := webrtc.ICERoleControlled
|
||||
|
||||
// Exchange the information
|
||||
fmt.Println(encode(s))
|
||||
remoteSignal := Signal{}
|
||||
|
||||
if *isOffer {
|
||||
signalingChan := httpSDPServer(*port)
|
||||
decode(<-signalingChan, &remoteSignal)
|
||||
|
||||
iceRole = webrtc.ICERoleControlling
|
||||
} else {
|
||||
decode(readUntilNewline(), &remoteSignal)
|
||||
}
|
||||
|
||||
if err = ice.SetRemoteCandidates(remoteSignal.ICECandidates); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start the ICE transport
|
||||
err = ice.Start(nil, remoteSignal.ICEParameters, &iceRole)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start the DTLS transport
|
||||
if err = dtls.Start(remoteSignal.DTLSParameters); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start the SCTP transport
|
||||
if err = sctp.Start(remoteSignal.SCTPCapabilities); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Construct the data channel as the offerer
|
||||
if *isOffer {
|
||||
var id uint16 = 1
|
||||
|
||||
dcParams := &webrtc.DataChannelParameters{
|
||||
Label: "Foo",
|
||||
ID: &id,
|
||||
}
|
||||
var channel *webrtc.DataChannel
|
||||
channel, err = api.NewDataChannel(sctp, dcParams)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Register the handlers
|
||||
// channel.OnOpen(handleOnOpen(channel)) // TODO: OnOpen on handle ChannelAck
|
||||
go handleOnOpen(channel)() // Temporary alternative
|
||||
channel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
fmt.Printf("Message from DataChannel '%s': '%s'\n", channel.Label(), string(msg.Data))
|
||||
})
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
// Signal is used to exchange signaling info.
|
||||
// This is not part of the ORTC spec. You are free
|
||||
// to exchange this information any way you want.
|
||||
type Signal struct {
|
||||
ICECandidates []webrtc.ICECandidate `json:"iceCandidates"`
|
||||
ICEParameters webrtc.ICEParameters `json:"iceParameters"`
|
||||
DTLSParameters webrtc.DTLSParameters `json:"dtlsParameters"`
|
||||
SCTPCapabilities webrtc.SCTPCapabilities `json:"sctpCapabilities"`
|
||||
}
|
||||
|
||||
func handleOnOpen(channel *webrtc.DataChannel) func() {
|
||||
return func() {
|
||||
fmt.Printf(
|
||||
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
|
||||
channel.Label(), channel.ID(),
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
message, err := randutil.GenerateCryptoRandomString(15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Sending %s \n", message)
|
||||
if err := channel.SendText(message); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj Signal) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *Signal) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// httpSDPServer starts a HTTP Server that consumes SDPs.
|
||||
func httpSDPServer(port int) chan string {
|
||||
sdpChan := make(chan string)
|
||||
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
fmt.Fprintf(res, "done") //nolint: errcheck
|
||||
sdpChan <- string(body)
|
||||
})
|
||||
|
||||
go func() {
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
|
||||
}()
|
||||
|
||||
return sdpChan
|
||||
}
|
||||
26
examples/pion-to-pion/README.md
Normal file
26
examples/pion-to-pion/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# pion-to-pion
|
||||
pion-to-pion is an example of two pion instances communicating directly!
|
||||
|
||||
The SDP offer and answer are exchanged automatically over HTTP.
|
||||
The `answer` side acts like a HTTP server and should therefore be ran first.
|
||||
|
||||
## Instructions
|
||||
First run `answer`:
|
||||
```sh
|
||||
go install github.com/pion/webrtc/v4/examples/pion-to-pion/answer@latest
|
||||
answer
|
||||
```
|
||||
Next, run `offer`:
|
||||
```sh
|
||||
go install github.com/pion/webrtc/v4/examples/pion-to-pion/offer@latest
|
||||
offer
|
||||
```
|
||||
|
||||
You should see them connect and start to exchange messages.
|
||||
|
||||
## You can use Docker-compose to start this example:
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Now, you can see message exchanging, using `docker logs`.
|
||||
10
examples/pion-to-pion/answer/Dockerfile
Normal file
10
examples/pion-to-pion/answer/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
FROM golang:1.27
|
||||
|
||||
RUN go install github.com/pion/webrtc/v4/examples/pion-to-pion/answer@latest
|
||||
|
||||
CMD ["answer"]
|
||||
|
||||
EXPOSE 50000
|
||||
213
examples/pion-to-pion/answer/main.go
Normal file
213
examples/pion-to-pion/answer/main.go
Normal file
@@ -0,0 +1,213 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// pion-to-pion is an example of two pion instances communicating directly!
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func signalCandidate(addr string, candidate *webrtc.ICECandidate) error {
|
||||
payload := []byte(candidate.ToJSON().Candidate)
|
||||
resp, err := http.Post( // nolint:noctx
|
||||
fmt.Sprintf("http://%s/candidate", addr),
|
||||
"application/json; charset=utf-8",
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
// nolint:gocognit, cyclop
|
||||
func main() {
|
||||
offerAddr := flag.String("offer-address", "127.0.0.1:50000", "Address that the Offer HTTP server is hosted on.")
|
||||
answerAddr := flag.String("answer-address", ":60000", "Address that the Answer HTTP server is hosted on.")
|
||||
flag.Parse()
|
||||
|
||||
var candidatesMux sync.Mutex
|
||||
pendingCandidates := make([]*webrtc.ICECandidate, 0)
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := peerConnection.Close(); closeErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// When an ICE candidate is available send to the other Pion instance
|
||||
// the other Pion instance will add this candidate by calling AddICECandidate
|
||||
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
|
||||
candidatesMux.Lock()
|
||||
defer candidatesMux.Unlock()
|
||||
|
||||
desc := peerConnection.RemoteDescription()
|
||||
if desc == nil {
|
||||
pendingCandidates = append(pendingCandidates, candidate)
|
||||
} else if onICECandidateErr := signalCandidate(*offerAddr, candidate); onICECandidateErr != nil {
|
||||
panic(onICECandidateErr)
|
||||
}
|
||||
})
|
||||
|
||||
// A HTTP handler that allows the other Pion instance to send us ICE candidates
|
||||
// This allows us to add ICE candidates faster, we don't have to wait for STUN or TURN
|
||||
// candidates which may be slower
|
||||
http.HandleFunc("/candidate", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
|
||||
candidate, candidateErr := io.ReadAll(req.Body)
|
||||
if candidateErr != nil {
|
||||
panic(candidateErr)
|
||||
}
|
||||
if candidateErr := peerConnection.AddICECandidate(
|
||||
webrtc.ICECandidateInit{Candidate: string(candidate)},
|
||||
); candidateErr != nil {
|
||||
panic(candidateErr)
|
||||
}
|
||||
})
|
||||
|
||||
// A HTTP handler that processes a SessionDescription given to us from the other Pion process
|
||||
http.HandleFunc("/sdp", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
|
||||
sdp := webrtc.SessionDescription{}
|
||||
if decodeErr := json.NewDecoder(req.Body).Decode(&sdp); decodeErr != nil {
|
||||
panic(decodeErr)
|
||||
}
|
||||
|
||||
if setErr := peerConnection.SetRemoteDescription(sdp); setErr != nil {
|
||||
panic(setErr)
|
||||
}
|
||||
|
||||
// Create an answer to send to the other process
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send our answer to the HTTP server listening in the other process
|
||||
payload, err := json.Marshal(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := http.Post( // nolint:noctx
|
||||
fmt.Sprintf("http://%s/sdp", *offerAddr),
|
||||
"application/json; charset=utf-8",
|
||||
bytes.NewReader(payload),
|
||||
) // nolint:noctx
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
panic(closeErr)
|
||||
}
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
candidatesMux.Lock()
|
||||
defer candidatesMux.Unlock()
|
||||
|
||||
for _, c := range pendingCandidates {
|
||||
if onICECandidateErr := signalCandidate(*offerAddr, c); onICECandidateErr != nil {
|
||||
panic(onICECandidateErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Register data channel creation handling
|
||||
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
|
||||
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
|
||||
setupDataChannel(dataChannel)
|
||||
})
|
||||
|
||||
// Start HTTP server that accepts requests from the offer process to exchange SDP and Candidates
|
||||
// nolint: gosec
|
||||
go func() { panic(http.ListenAndServe(*answerAddr, nil)) }()
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
func setupDataChannel(dataChannel *webrtc.DataChannel) {
|
||||
// Register channel opening handling
|
||||
dataChannel.OnOpen(func() {
|
||||
fmt.Printf(
|
||||
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
|
||||
dataChannel.Label(), dataChannel.ID(),
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
message, sendTextErr := randutil.GenerateCryptoRandomString(
|
||||
15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
)
|
||||
if sendTextErr != nil {
|
||||
panic(sendTextErr)
|
||||
}
|
||||
|
||||
// Send the message as text
|
||||
fmt.Printf("Sending '%s'\n", message)
|
||||
if sendTextErr = dataChannel.SendText(message); sendTextErr != nil {
|
||||
panic(sendTextErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Register text message handling
|
||||
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), string(msg.Data))
|
||||
})
|
||||
}
|
||||
15
examples/pion-to-pion/docker-compose.yml
Normal file
15
examples/pion-to-pion/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
version: '3'
|
||||
services:
|
||||
answer:
|
||||
container_name: answer
|
||||
build: ./answer
|
||||
command: answer -offer-address offer:50000
|
||||
|
||||
offer:
|
||||
container_name: offer
|
||||
depends_on:
|
||||
- answer
|
||||
build: ./offer
|
||||
command: offer -answer-address answer:60000
|
||||
8
examples/pion-to-pion/offer/Dockerfile
Normal file
8
examples/pion-to-pion/offer/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
FROM golang:1.27
|
||||
|
||||
RUN go install github.com/pion/webrtc/v4/examples/pion-to-pion/offer@latest
|
||||
|
||||
CMD ["offer"]
|
||||
215
examples/pion-to-pion/offer/main.go
Normal file
215
examples/pion-to-pion/offer/main.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// pion-to-pion is an example of two pion instances communicating directly!
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func signalCandidate(addr string, candidate *webrtc.ICECandidate) error {
|
||||
payload := []byte(candidate.ToJSON().Candidate)
|
||||
resp, err := http.Post( // nolint:noctx
|
||||
fmt.Sprintf("http://%s/candidate", addr),
|
||||
"application/json; charset=utf-8",
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
// nolint:gocognit, cyclop
|
||||
func main() {
|
||||
offerAddr := flag.String("offer-address", ":50000", "Address that the Offer HTTP server is hosted on.")
|
||||
answerAddr := flag.String("answer-address", "127.0.0.1:60000", "Address that the Answer HTTP server is hosted on.")
|
||||
flag.Parse()
|
||||
|
||||
var candidatesMux sync.Mutex
|
||||
pendingCandidates := make([]*webrtc.ICECandidate, 0)
|
||||
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := peerConnection.Close(); closeErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// When an ICE candidate is available send to the other Pion instance
|
||||
// the other Pion instance will add this candidate by calling AddICECandidate
|
||||
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
|
||||
candidatesMux.Lock()
|
||||
defer candidatesMux.Unlock()
|
||||
|
||||
desc := peerConnection.RemoteDescription()
|
||||
if desc == nil {
|
||||
pendingCandidates = append(pendingCandidates, candidate)
|
||||
} else if onICECandidateErr := signalCandidate(*answerAddr, candidate); onICECandidateErr != nil {
|
||||
panic(onICECandidateErr)
|
||||
}
|
||||
})
|
||||
|
||||
// A HTTP handler that allows the other Pion instance to send us ICE candidates
|
||||
// This allows us to add ICE candidates faster, we don't have to wait for STUN or TURN
|
||||
// candidates which may be slower
|
||||
http.HandleFunc("/candidate", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
|
||||
candidate, candidateErr := io.ReadAll(req.Body)
|
||||
if candidateErr != nil {
|
||||
panic(candidateErr)
|
||||
}
|
||||
if candidateErr := peerConnection.AddICECandidate(
|
||||
webrtc.ICECandidateInit{Candidate: string(candidate)},
|
||||
); candidateErr != nil {
|
||||
panic(candidateErr)
|
||||
}
|
||||
})
|
||||
|
||||
// A HTTP handler that processes a SessionDescription given to us from the other Pion process
|
||||
http.HandleFunc("/sdp", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
|
||||
sdp := webrtc.SessionDescription{}
|
||||
if decodeErr := json.NewDecoder(req.Body).Decode(&sdp); decodeErr != nil {
|
||||
panic(decodeErr)
|
||||
}
|
||||
|
||||
if setErr := peerConnection.SetRemoteDescription(sdp); setErr != nil {
|
||||
panic(setErr)
|
||||
}
|
||||
|
||||
candidatesMux.Lock()
|
||||
defer candidatesMux.Unlock()
|
||||
|
||||
for _, c := range pendingCandidates {
|
||||
if onICECandidateErr := signalCandidate(*answerAddr, c); onICECandidateErr != nil {
|
||||
panic(onICECandidateErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Create a datachannel with label 'data'
|
||||
dataChannel, err := peerConnection.CreateDataChannel("data", nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
setupDataChannel(dataChannel)
|
||||
|
||||
// Start HTTP server that accepts requests from the answer process to exchange SDP and Candidates
|
||||
// nolint: gosec
|
||||
go func() { panic(http.ListenAndServe(*offerAddr, nil)) }()
|
||||
|
||||
// Create an offer to send to the other process
|
||||
offer, err := peerConnection.CreateOffer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
// Note: this will start the gathering of ICE candidates
|
||||
if err = peerConnection.SetLocalDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send our offer to the HTTP server listening in the other process
|
||||
payload, err := json.Marshal(offer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := http.Post( // nolint:noctx
|
||||
fmt.Sprintf("http://%s/sdp", *answerAddr),
|
||||
"application/json; charset=utf-8",
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err := resp.Body.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
func setupDataChannel(dataChannel *webrtc.DataChannel) {
|
||||
// Register channel opening handling
|
||||
dataChannel.OnOpen(func() {
|
||||
fmt.Printf(
|
||||
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
|
||||
dataChannel.Label(), dataChannel.ID(),
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
message, sendTextErr := randutil.GenerateCryptoRandomString(
|
||||
15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
)
|
||||
if sendTextErr != nil {
|
||||
panic(sendTextErr)
|
||||
}
|
||||
|
||||
// Send the message as text
|
||||
fmt.Printf("Sending '%s'\n", message)
|
||||
if sendTextErr = dataChannel.SendText(message); sendTextErr != nil {
|
||||
panic(sendTextErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Register text message handling
|
||||
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), string(msg.Data))
|
||||
})
|
||||
}
|
||||
17
examples/pion-to-pion/test.sh
Executable file
17
examples/pion-to-pion/test.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash -eu
|
||||
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
docker compose up -d
|
||||
|
||||
function on_exit {
|
||||
docker compose logs
|
||||
docker compose rm -fsv
|
||||
}
|
||||
|
||||
trap on_exit EXIT
|
||||
|
||||
TIMEOUT=10
|
||||
timeout $TIMEOUT docker compose logs -f | grep -q "answer | Message from DataChannel"
|
||||
timeout $TIMEOUT docker compose logs -f | grep -q "offer | Message from DataChannel"
|
||||
43
examples/play-from-disk-fec/README.md
Normal file
43
examples/play-from-disk-fec/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# play-from-disk-fec
|
||||
play-from-disk-fec demonstrates how to use forward error correction (FlexFEC-03) while sending video to your Chrome-based browser from files saved to disk. The example is designed to drop 40% of the media packets, but browser will recover them using the FEC packets and the delivered packets.
|
||||
|
||||
## Instructions
|
||||
### Create IVF named `output.ivf` that contains a VP8/VP9/AV1 track
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -g 30 -b:v 2M output.ivf
|
||||
```
|
||||
|
||||
**Note**: In the `ffmpeg` command which produces the .ivf file, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
|
||||
|
||||
### Download play-from-disk-fec
|
||||
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/play-from-disk-fec@latest
|
||||
```
|
||||
|
||||
### Open play-from-disk-fec example page
|
||||
Open [jsfiddle.net](https://jsfiddle.net/hgzwr9cm/) in your browser. You should see two text-areas and buttons for the offer-answer exchange.
|
||||
|
||||
### Run play-from-disk-fec to generate an offer
|
||||
The `output.ivf` you created should be in the same directory as `play-from-disk-fec`.
|
||||
|
||||
When you run play-from-disk-fec, it will generate an offer in base64 format and print it to stdout.
|
||||
|
||||
### Input play-from-disk-fec's offer into your browser
|
||||
Copy the base64 offer that `play-from-disk-fec` just emitted and paste it into the first text area in the jsfiddle (labeled "Remote Session Description")
|
||||
|
||||
### Hit 'Start Session' in jsfiddle to generate an answer
|
||||
Click the 'Start Session' button. This will process the offer and generate an answer, which will appear in the second text area.
|
||||
|
||||
### Save the browser's answer to a file
|
||||
Copy the base64-encoded answer from the second text area (labeled "Browser Session Description") and save it to a file named `answer.txt` in the same directory where you're running `play-from-disk-fec`.
|
||||
|
||||
### Press Enter to continue
|
||||
Once you've saved the answer to `answer.txt`, go back to the terminal where `play-from-disk-fec` is running and press Enter. The program will read the answer file and establish the connection.
|
||||
|
||||
### Enjoy your video!
|
||||
A video should start playing in your browser above the input boxes. `play-from-disk-fec` will exit when the file reaches the end
|
||||
|
||||
You can watch the stats about transmitted/dropped media & FEC packets in the stdout.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
8
examples/play-from-disk-fec/jsfiddle/demo.css
Normal file
8
examples/play-from-disk-fec/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/play-from-disk-fec/jsfiddle/demo.details
Normal file
8
examples/play-from-disk-fec/jsfiddle/demo.details
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: play-from-disk-fec
|
||||
description: play-from-disk-fec demonstrates how to use forward error correction (FlexFEC-03) while sending video to your Chrome-based browser from files saved to disk.
|
||||
authors:
|
||||
- Aleksandr Alekseev
|
||||
30
examples/play-from-disk-fec/jsfiddle/demo.html
Normal file
30
examples/play-from-disk-fec/jsfiddle/demo.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
Remote Session Description (Paste offer from Go code here)
|
||||
<br/>
|
||||
<textarea id="remoteSessionDescription"></textarea>
|
||||
<br/>
|
||||
<button onclick="window.startSession()">Start Session</button>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
Browser Session Description (Copy this to answer.txt file)
|
||||
<br/>
|
||||
<textarea id="localSessionDescription" readonly="true"></textarea>
|
||||
<br/>
|
||||
|
||||
<button onclick="window.copySessionDescription()">Copy browser Session Description to clipboard</button>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
Video
|
||||
<br/>
|
||||
<div id="remoteVideos"></div> <br />
|
||||
|
||||
Logs
|
||||
<br/>
|
||||
<div id="div"></div>
|
||||
73
examples/play-from-disk-fec/jsfiddle/demo.js
Normal file
73
examples/play-from-disk-fec/jsfiddle/demo.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}
|
||||
]
|
||||
})
|
||||
const log = (msg) => {
|
||||
document.getElementById('div').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
const el = document.createElement(event.track.kind)
|
||||
el.srcObject = event.streams[0]
|
||||
el.autoplay = true
|
||||
el.controls = true
|
||||
|
||||
document.getElementById('remoteVideos').appendChild(el)
|
||||
}
|
||||
|
||||
pc.oniceconnectionstatechange = (e) => log(pc.iceConnectionState)
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate === null) {
|
||||
document.getElementById('localSessionDescription').value = btoa(
|
||||
JSON.stringify(pc.localDescription)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.startSession = () => {
|
||||
const sd = document.getElementById('remoteSessionDescription').value
|
||||
if (sd === '') {
|
||||
return alert('Session Description must not be empty')
|
||||
}
|
||||
|
||||
try {
|
||||
// Set the remote offer
|
||||
pc.setRemoteDescription(JSON.parse(atob(sd)))
|
||||
.then(() => {
|
||||
// Create answer
|
||||
return pc.createAnswer()
|
||||
})
|
||||
.then((answer) => {
|
||||
// Set local description with the answer
|
||||
return pc.setLocalDescription(answer)
|
||||
})
|
||||
.catch(log)
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}
|
||||
|
||||
window.copySessionDescription = () => {
|
||||
const browserSessionDescription = document.getElementById(
|
||||
'localSessionDescription'
|
||||
)
|
||||
|
||||
browserSessionDescription.focus()
|
||||
browserSessionDescription.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
const msg = successful ? 'successful' : 'unsuccessful'
|
||||
log('Copying SessionDescription was ' + msg)
|
||||
} catch (err) {
|
||||
log('Oops, unable to copy SessionDescription ' + err)
|
||||
}
|
||||
}
|
||||
334
examples/play-from-disk-fec/main.go
Normal file
334
examples/play-from-disk-fec/main.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// play-from-disk-fec demonstrates how to use forward error correction (FlexFEC-03)
|
||||
// while sending video to your Chrome-based browser from files saved to disk.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
)
|
||||
|
||||
const (
|
||||
videoFileName = "output.ivf"
|
||||
answerFileName = "answer.txt"
|
||||
)
|
||||
|
||||
func main() { //nolint:gocognit,cyclop,gocyclo,maintidx
|
||||
// Assert that we have a video file
|
||||
_, err := os.Stat(videoFileName)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
panic("Could not find `" + videoFileName + "`")
|
||||
}
|
||||
|
||||
// Create mediaEngine with default codecs
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if err = mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create interceptorRegistry with default interceptots
|
||||
interceptorRegistry := &interceptor.Registry{}
|
||||
|
||||
interceptorRegistry.Add(packetDropInterceptorFactory{})
|
||||
|
||||
// Configure flexfec-03
|
||||
if err = webrtc.ConfigureFlexFEC03(49, mediaEngine, interceptorRegistry); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
api := webrtc.NewAPI(
|
||||
webrtc.WithMediaEngine(mediaEngine),
|
||||
webrtc.WithInterceptorRegistry(interceptorRegistry),
|
||||
)
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
|
||||
|
||||
file, openErr := os.Open(videoFileName)
|
||||
if openErr != nil {
|
||||
panic(openErr)
|
||||
}
|
||||
|
||||
_, header, openErr := ivfreader.NewWith(file)
|
||||
if openErr != nil {
|
||||
panic(openErr)
|
||||
}
|
||||
|
||||
// Determine video codec
|
||||
var trackCodec string
|
||||
switch header.FourCC {
|
||||
case "AV01":
|
||||
trackCodec = webrtc.MimeTypeAV1
|
||||
case "VP90":
|
||||
trackCodec = webrtc.MimeTypeVP9
|
||||
case "VP80":
|
||||
trackCodec = webrtc.MimeTypeVP8
|
||||
default:
|
||||
panic(fmt.Sprintf("Unable to handle FourCC %s", header.FourCC))
|
||||
}
|
||||
|
||||
// Create a video track
|
||||
videoTrack, videoTrackErr := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion",
|
||||
)
|
||||
if videoTrackErr != nil {
|
||||
panic(videoTrackErr)
|
||||
}
|
||||
|
||||
rtpSender, videoTrackErr := peerConnection.AddTrack(videoTrack)
|
||||
if videoTrackErr != nil {
|
||||
panic(videoTrackErr)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
// Open a IVF file and start reading using our IVFReader
|
||||
file, ivfErr := os.Open(videoFileName)
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
ivf, header, ivfErr := ivfreader.NewWith(file)
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
// Wait for connection established
|
||||
<-iceConnectedCtx.Done()
|
||||
|
||||
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
|
||||
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
|
||||
//
|
||||
// It is important to use a time.Ticker instead of time.Sleep because
|
||||
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
|
||||
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
|
||||
ticker := time.NewTicker(
|
||||
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
|
||||
)
|
||||
defer ticker.Stop()
|
||||
for ; true; <-ticker.C {
|
||||
frame, _, ivfErr := ivf.ParseNextFrame()
|
||||
if errors.Is(ivfErr, io.EOF) {
|
||||
fmt.Printf("All video frames parsed and sent")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
if ivfErr = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("Connection State has changed %s \n", connectionState.String())
|
||||
if connectionState == webrtc.ICEConnectionStateConnected {
|
||||
iceConnectedCtxCancel()
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Create offer
|
||||
offer, err := peerConnection.CreateOffer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
if err = peerConnection.SetLocalDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the offer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Wait for user to save the answer and press enter
|
||||
fmt.Printf("Save the browser's answer to '%s' and press Enter to continue...\n", answerFileName)
|
||||
_, err = bufio.NewReader(os.Stdin).ReadBytes('\n')
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Read the answer from file
|
||||
answerData, readErr := os.ReadFile(answerFileName)
|
||||
if readErr != nil {
|
||||
panic(readErr)
|
||||
}
|
||||
|
||||
answerStr := strings.TrimSpace(string(answerData))
|
||||
if len(answerStr) == 0 {
|
||||
panic("Answer file is empty")
|
||||
}
|
||||
|
||||
answer := webrtc.SessionDescription{}
|
||||
decode(answerStr, &answer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
if err = peerConnection.SetRemoteDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println("Answer received and set successfully!")
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Factory for creating the interceptor.
|
||||
type packetDropInterceptorFactory struct{}
|
||||
|
||||
func (f packetDropInterceptorFactory) NewInterceptor(_ string) (interceptor.Interceptor, error) {
|
||||
return &dropFilter{}, nil
|
||||
}
|
||||
|
||||
// dropFilter drops outgoing video packets based on sequence number.
|
||||
type dropFilter struct {
|
||||
interceptor.NoOp
|
||||
mu sync.Mutex
|
||||
mediaPacketsTotal int
|
||||
fecPacketsTotal int
|
||||
droppedPacketsTotal int
|
||||
}
|
||||
|
||||
func (i *dropFilter) BindLocalStream(info *interceptor.StreamInfo, writer interceptor.RTPWriter) interceptor.RTPWriter {
|
||||
if !strings.HasPrefix(strings.ToLower(info.MimeType), "video/") {
|
||||
return writer
|
||||
}
|
||||
|
||||
return interceptor.RTPWriterFunc(func(header *rtp.Header, payload []byte, attrs interceptor.Attributes) (int, error) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
// Check if this is a FEC packet
|
||||
if header.SSRC == info.SSRCForwardErrorCorrection {
|
||||
i.fecPacketsTotal++
|
||||
|
||||
return writer.Write(header, payload, attrs)
|
||||
}
|
||||
|
||||
// Log stats periodically
|
||||
if i.mediaPacketsTotal%100 == 0 {
|
||||
dropRatio := float64(i.droppedPacketsTotal) / float64(i.mediaPacketsTotal)
|
||||
fmt.Printf("Stats: Media: %d, FEC: %d, Dropped: %d, Drop ratio: %.4f%%\n",
|
||||
i.mediaPacketsTotal, i.fecPacketsTotal, i.droppedPacketsTotal, dropRatio*100)
|
||||
}
|
||||
|
||||
// Count all media packets
|
||||
i.mediaPacketsTotal++
|
||||
|
||||
// 40% loss
|
||||
if i.mediaPacketsTotal%5 <= 1 {
|
||||
i.droppedPacketsTotal++
|
||||
|
||||
return len(payload), nil // Pretend we wrote the packet but actually drop it
|
||||
}
|
||||
|
||||
return writer.Write(header, payload, attrs)
|
||||
})
|
||||
}
|
||||
49
examples/play-from-disk-playlist-control/README.md
Normal file
49
examples/play-from-disk-playlist-control/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# ogg-playlist-sctp
|
||||
Streams Opus pages from multi or single track Ogg containers, exposes the playlist over an SCTP DataChannel, and lets the browser hop between tracks while showing artist/title metadata parsed from OpusTags.
|
||||
|
||||
## What this showcases
|
||||
- Reads multi-stream Ogg containers with `oggreader` and keeps per-serial playback state.
|
||||
- Publishes playlist + now-playing metadata (artist/title/vendor/comments) over a DataChannel.
|
||||
- Browser can send `next`, `prev`, or a 1-based track number to jump around.
|
||||
- Audio is sent as an Opus `TrackLocalStaticSample` over RTP, metadata/control ride over SCTP.
|
||||
|
||||
## Prepare a demo playlist
|
||||
The example looks for `playlist.ogg` in the working directory.
|
||||
You can provide your own `playlist.ogg` or generate it by running one of the following ffmpeg commands:
|
||||
|
||||
**Fake two-track Ogg with metadata (artist/title per stream)**
|
||||
```sh
|
||||
ffmpeg \
|
||||
-f lavfi -t 8 -i "sine=frequency=330" \
|
||||
-f lavfi -t 8 -i "sine=frequency=660" \
|
||||
-map 0:a -map 1:a \
|
||||
-c:a libopus -page_duration 20000 \
|
||||
-metadata:s:a:0 artist="Pion Artist" -metadata:s:a:0 title="Fake Intro" \
|
||||
-metadata:s:a:1 artist="Open-Source Friend" -metadata:s:a:1 title="Fake Outro" \
|
||||
playlist.ogg
|
||||
```
|
||||
|
||||
**Single-track fallback with tags**
|
||||
```sh
|
||||
ffmpeg -f lavfi -t 10 -i "sine=frequency=480" \
|
||||
-c:a libopus -page_duration 20000 \
|
||||
-metadata artist="Solo Bot" -metadata title="One Track Demo" \
|
||||
playlist.ogg
|
||||
```
|
||||
|
||||
## Run it
|
||||
1. Build the binary:
|
||||
```sh
|
||||
go install github.com/pion/webrtc/v4/examples/play-from-disk-playlist-control@latest
|
||||
```
|
||||
2. Run it from the directory containing `playlist.ogg` (override port with `-addr` if you like):
|
||||
```sh
|
||||
play-from-disk-playlist-control
|
||||
# or
|
||||
play-from-disk-playlist-control -addr :8080
|
||||
```
|
||||
3. Open the hosted UI in your browser and press **Start Session**:
|
||||
```
|
||||
http://localhost:8080
|
||||
```
|
||||
Signaling is WHEP-style: the browser POSTs plain SDP to `/whep` and the server responds with the answer SDP. Use the buttons or type `next` / `prev` / a track number to switch tracks. Playlist metadata and now-playing updates arrive over the DataChannel; Opus audio flows on the media track.
|
||||
541
examples/play-from-disk-playlist-control/main.go
Normal file
541
examples/play-from-disk-playlist-control/main.go
Normal file
@@ -0,0 +1,541 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// ogg-playlist-sctp streams Opus pages from single or multi-track Ogg containers,
|
||||
// exposes the playlist over a DataChannel, and lets the browser switch tracks.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/oggreader"
|
||||
)
|
||||
|
||||
const (
|
||||
playlistFile = "playlist.ogg"
|
||||
labelAudio = "audio"
|
||||
labelTrack = "pion"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var content embed.FS
|
||||
|
||||
type bufferedPage struct {
|
||||
payload []byte
|
||||
duration time.Duration
|
||||
granule uint64
|
||||
}
|
||||
|
||||
type oggTrack struct {
|
||||
serial uint32
|
||||
header *oggreader.OggHeader
|
||||
tags *oggreader.OpusTags
|
||||
|
||||
title string
|
||||
artist string
|
||||
vendor string
|
||||
pages []bufferedPage
|
||||
runtime time.Duration
|
||||
}
|
||||
|
||||
func main() { //nolint:gocognit,cyclop
|
||||
addr := flag.String("addr", "localhost:8080", "HTTP listen address")
|
||||
flag.Parse()
|
||||
|
||||
tracks, err := parsePlaylist(playlistFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(tracks) == 0 {
|
||||
log.Fatal("no playable Opus pages were found in playlist.ogg")
|
||||
}
|
||||
|
||||
log.Printf("Loaded %d track(s) from %s", len(tracks), playlistFile)
|
||||
for i, t := range tracks {
|
||||
log.Printf(" [%d] serial=%d title=%q artist=%q pages=%d duration=%v",
|
||||
i+1, t.serial, t.title, t.artist, len(t.pages), t.runtime)
|
||||
}
|
||||
|
||||
static, err := fs.Sub(content, "web")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
fileServer := http.FileServer(http.FS(static))
|
||||
mux.Handle("/", fileServer)
|
||||
mux.HandleFunc("/whep", func(writer http.ResponseWriter, reader *http.Request) {
|
||||
if reader.Method != http.MethodPost {
|
||||
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(reader.Body)
|
||||
if err != nil {
|
||||
http.Error(writer, "failed to read body", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
rawSDP := string(body)
|
||||
if strings.TrimSpace(rawSDP) == "" {
|
||||
http.Error(writer, "empty SDP", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
log.Printf("received offer (%d bytes)", len(rawSDP))
|
||||
|
||||
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: rawSDP}
|
||||
|
||||
answer, err := handleOffer(tracks, offer) //nolint:contextcheck
|
||||
if err != nil {
|
||||
log.Printf("error handling offer: %v", err)
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/sdp")
|
||||
if _, err = writer.Write([]byte(answer.SDP)); err != nil {
|
||||
log.Printf("write answer failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
log.Printf("Serving UI at http://%s ...", *addr)
|
||||
log.Fatal(http.ListenAndServe(*addr, mux)) //nolint:gosec
|
||||
}
|
||||
|
||||
//nolint:cyclop
|
||||
func handleOffer(
|
||||
tracks []*oggTrack,
|
||||
offer webrtc.SessionDescription,
|
||||
) (*webrtc.SessionDescription, error) {
|
||||
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create PeerConnection: %w", err)
|
||||
}
|
||||
|
||||
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
|
||||
disconnectCtx, disconnectCtxCancel := context.WithCancel(context.Background())
|
||||
setupComplete := false
|
||||
defer func() {
|
||||
if !setupComplete {
|
||||
iceConnectedCtxCancel()
|
||||
disconnectCtxCancel()
|
||||
}
|
||||
}()
|
||||
|
||||
audioTrack, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||
labelAudio,
|
||||
labelTrack,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create audio track: %w", err)
|
||||
}
|
||||
|
||||
rtpSender, err := peerConnection.AddTrack(audioTrack)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add track: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
playlistChannel, err := peerConnection.CreateDataChannel("playlist", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create data channel: %w", err)
|
||||
}
|
||||
|
||||
var currentTrack atomic.Int32
|
||||
switchTrack := make(chan int, 4)
|
||||
|
||||
playlistChannel.OnOpen(func() {
|
||||
fmt.Println("playlist data channel open")
|
||||
sendPlaylistText(playlistChannel, tracks, int(currentTrack.Load()), true)
|
||||
})
|
||||
|
||||
playlistChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
command := strings.TrimSpace(strings.ToLower(string(msg.Data)))
|
||||
limit := len(tracks)
|
||||
next := -1
|
||||
switch command {
|
||||
case "next", "n", "forward":
|
||||
next = wrapNext(int(currentTrack.Load()), limit)
|
||||
case "prev", "previous", "p", "back":
|
||||
next = wrapPrev(int(currentTrack.Load()), limit)
|
||||
case "list":
|
||||
sendPlaylistText(playlistChannel, tracks, int(currentTrack.Load()), true)
|
||||
default:
|
||||
if idx, convErr := strconv.Atoi(command); convErr == nil {
|
||||
next = normalizeIndex(idx-1, limit)
|
||||
}
|
||||
}
|
||||
|
||||
if next < 0 || next == int(currentTrack.Load()) {
|
||||
return
|
||||
}
|
||||
|
||||
currentTrack.Store(int32(next)) //nolint:gosec
|
||||
select {
|
||||
case switchTrack <- next:
|
||||
default:
|
||||
}
|
||||
sendPlaylistText(playlistChannel, tracks, next, true)
|
||||
})
|
||||
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("Connection State has changed %s\n", connectionState.String())
|
||||
if connectionState == webrtc.ICEConnectionStateConnected {
|
||||
iceConnectedCtxCancel()
|
||||
}
|
||||
})
|
||||
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed || state == webrtc.PeerConnectionStateClosed {
|
||||
disconnectCtxCancel()
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
<-iceConnectedCtx.Done()
|
||||
stream(tracks, audioTrack, ¤tTrack, switchTrack, playlistChannel, disconnectCtx)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-disconnectCtx.Done()
|
||||
if closeErr := peerConnection.Close(); closeErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
//nolint:contextcheck // webrtc API does not take context for SetRemoteDescription
|
||||
if err = peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
return nil, fmt.Errorf("set remote description: %w", err)
|
||||
}
|
||||
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create answer: %w", err)
|
||||
}
|
||||
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
return nil, fmt.Errorf("set local description: %w", err)
|
||||
}
|
||||
|
||||
<-gatherComplete
|
||||
setupComplete = true
|
||||
|
||||
return peerConnection.LocalDescription(), nil
|
||||
}
|
||||
|
||||
func stream(
|
||||
tracks []*oggTrack,
|
||||
audioTrack *webrtc.TrackLocalStaticSample,
|
||||
currentTrack *atomic.Int32,
|
||||
switchTrack <-chan int,
|
||||
playlistChannel *webrtc.DataChannel,
|
||||
ctx context.Context,
|
||||
) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
index := normalizeIndex(int(currentTrack.Load()), len(tracks))
|
||||
track := tracks[index]
|
||||
sendNowPlayingText(playlistChannel, track, index)
|
||||
|
||||
for i := 0; i < len(track.pages); i++ {
|
||||
page := track.pages[i]
|
||||
if err := audioTrack.WriteSample(media.Sample{Data: page.payload, Duration: page.duration}); err != nil {
|
||||
if errors.Is(err, io.ErrClosedPipe) {
|
||||
return
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
|
||||
wait := time.After(page.duration)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case next := <-switchTrack:
|
||||
currentTrack.Store(int32(normalizeIndex(next, len(tracks)))) //nolint:gosec
|
||||
|
||||
goto nextTrack
|
||||
case <-wait:
|
||||
}
|
||||
}
|
||||
|
||||
nextTrack:
|
||||
}
|
||||
}
|
||||
|
||||
func parsePlaylist(path string) ([]*oggTrack, error) { //nolint:cyclop
|
||||
cleaned := filepath.Clean(path)
|
||||
if filepath.IsAbs(cleaned) || strings.Contains(cleaned, "..") {
|
||||
return nil, fmt.Errorf("invalid playlist path: %q", path) //nolint:err113
|
||||
}
|
||||
cleaned = filepath.Base(cleaned)
|
||||
|
||||
file, err := os.Open(cleaned) //nolint:gosec // path is validated and confined to local directory
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open playlist %q: %w", cleaned, err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := file.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close ogg file: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
reader, err := oggreader.NewWithOptions(file, oggreader.WithDoChecksum(false))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ogg reader: %w", err)
|
||||
}
|
||||
|
||||
tracks := map[uint32]*oggTrack{}
|
||||
var order []uint32
|
||||
lastGranule := map[uint32]uint64{}
|
||||
|
||||
for {
|
||||
payload, pageHeader, parseErr := reader.ParseNextPage()
|
||||
if errors.Is(parseErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse ogg page: %w", parseErr)
|
||||
}
|
||||
|
||||
track := ensureTrack(tracks, pageHeader.Serial, &order)
|
||||
if headerType, ok := pageHeader.HeaderType(payload); ok { //nolint:nestif
|
||||
switch headerType {
|
||||
case oggreader.HeaderOpusID:
|
||||
header, headerErr := oggreader.ParseOpusHead(payload)
|
||||
if headerErr != nil {
|
||||
return nil, fmt.Errorf("parse OpusHead: %w", headerErr)
|
||||
}
|
||||
track.header = header
|
||||
|
||||
continue
|
||||
case oggreader.HeaderOpusTags:
|
||||
tags, tagErr := oggreader.ParseOpusTags(payload)
|
||||
if tagErr != nil {
|
||||
return nil, fmt.Errorf("parse OpusTags: %w", tagErr)
|
||||
}
|
||||
track.tags = tags
|
||||
track.title, track.artist = extractMetadata(tags)
|
||||
if track.vendor == "" {
|
||||
track.vendor = tags.Vendor
|
||||
}
|
||||
|
||||
continue
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
if track.header == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
duration := pageDuration(track.header, pageHeader.GranulePosition, lastGranule[track.serial])
|
||||
lastGranule[track.serial] = pageHeader.GranulePosition
|
||||
track.pages = append(track.pages, bufferedPage{
|
||||
payload: payload,
|
||||
duration: duration,
|
||||
granule: pageHeader.GranulePosition,
|
||||
})
|
||||
track.runtime += duration
|
||||
}
|
||||
|
||||
var ordered []*oggTrack
|
||||
for _, serial := range order {
|
||||
track := tracks[serial]
|
||||
if len(track.pages) == 0 {
|
||||
continue
|
||||
}
|
||||
if track.title == "" {
|
||||
track.title = fmt.Sprintf("Track %d", len(ordered)+1)
|
||||
}
|
||||
ordered = append(ordered, track)
|
||||
}
|
||||
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
func ensureTrack(tracks map[uint32]*oggTrack, serial uint32, order *[]uint32) *oggTrack {
|
||||
track, ok := tracks[serial]
|
||||
if ok {
|
||||
return track
|
||||
}
|
||||
|
||||
track = &oggTrack{serial: serial, title: fmt.Sprintf("serial-%d", serial)}
|
||||
tracks[serial] = track
|
||||
*order = append(*order, serial)
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
func extractMetadata(tags *oggreader.OpusTags) (title, artist string) {
|
||||
for _, c := range tags.UserComments {
|
||||
switch strings.ToLower(c.Comment) {
|
||||
case "title":
|
||||
title = c.Value
|
||||
case "artist":
|
||||
artist = c.Value
|
||||
}
|
||||
}
|
||||
|
||||
return title, artist
|
||||
}
|
||||
|
||||
func pageDuration(header *oggreader.OggHeader, granule, last uint64) time.Duration {
|
||||
sampleRate := header.SampleRate
|
||||
if sampleRate == 0 {
|
||||
sampleRate = 48000
|
||||
}
|
||||
|
||||
if granule <= last {
|
||||
return 20 * time.Millisecond
|
||||
}
|
||||
|
||||
sampleCount := int64(granule - last) //nolint:gosec
|
||||
if sampleCount <= 0 {
|
||||
return 20 * time.Millisecond
|
||||
}
|
||||
|
||||
ns := float64(sampleCount) / float64(sampleRate) * float64(time.Second)
|
||||
|
||||
return time.Duration(ns)
|
||||
}
|
||||
|
||||
func wrapNext(current, limit int) int {
|
||||
if limit == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (current + 1) % limit
|
||||
}
|
||||
|
||||
func wrapPrev(current, limit int) int {
|
||||
if limit == 0 {
|
||||
return 0
|
||||
}
|
||||
if current == 0 {
|
||||
return limit - 1
|
||||
}
|
||||
|
||||
return current - 1
|
||||
}
|
||||
|
||||
func normalizeIndex(i, limit int) int {
|
||||
if limit == 0 {
|
||||
return 0
|
||||
}
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
if i >= limit {
|
||||
return limit - 1
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
func sendPlaylistText(dc *webrtc.DataChannel, tracks []*oggTrack, current int, includeNow bool) {
|
||||
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
|
||||
return
|
||||
}
|
||||
|
||||
var str strings.Builder
|
||||
fmt.Fprintf(&str, "playlist|%d\n", normalizeIndex(current, len(tracks)))
|
||||
for i, t := range tracks {
|
||||
fmt.Fprintf(
|
||||
&str, "track|%d|%d|%d|%s|%s\n", i, t.serial, t.runtime.Milliseconds(),
|
||||
cleanText(t.title),
|
||||
cleanText(t.artist),
|
||||
)
|
||||
}
|
||||
if includeNow && len(tracks) > 0 {
|
||||
next := normalizeIndex(current, len(tracks))
|
||||
str.WriteString(nowLine(tracks[next], next))
|
||||
}
|
||||
|
||||
if err := dc.SendText(str.String()); err != nil {
|
||||
fmt.Printf("unable to send playlist: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sendNowPlayingText(dc *webrtc.DataChannel, track *oggTrack, index int) {
|
||||
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
|
||||
return
|
||||
}
|
||||
|
||||
line := nowLine(track, index)
|
||||
if err := dc.SendText(line); err != nil {
|
||||
fmt.Printf("unable to send now-playing: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func nowLine(track *oggTrack, index int) string {
|
||||
comments := ""
|
||||
if track.tags != nil && len(track.tags.UserComments) > 0 {
|
||||
pairs := make([]string, 0, len(track.tags.UserComments))
|
||||
for _, c := range track.tags.UserComments {
|
||||
pairs = append(pairs, cleanText(c.Comment)+"="+cleanText(c.Value))
|
||||
}
|
||||
comments = strings.Join(pairs, ",")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"now|%d|%d|%d|%d|%d|%s|%s|%s|%s\n",
|
||||
index,
|
||||
track.serial,
|
||||
track.header.Channels,
|
||||
track.header.SampleRate,
|
||||
track.runtime.Milliseconds(),
|
||||
cleanText(track.title),
|
||||
cleanText(track.artist),
|
||||
cleanText(track.vendor),
|
||||
comments,
|
||||
)
|
||||
}
|
||||
|
||||
func cleanText(v string) string {
|
||||
out := strings.ReplaceAll(v, "\n", " ")
|
||||
|
||||
return strings.ReplaceAll(out, "|", "/")
|
||||
}
|
||||
144
examples/play-from-disk-playlist-control/web/app.css
Normal file
144
examples/play-from-disk-playlist-control/web/app.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 1.5rem;
|
||||
color: #121212;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #eef1f7;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
padding: 0.5rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0b5ed7;
|
||||
}
|
||||
|
||||
.player {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.logs {
|
||||
min-height: 180px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid #eceff4;
|
||||
}
|
||||
|
||||
.list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list .current {
|
||||
font-weight: bold;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85rem;
|
||||
color: #5a6572;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.track {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.artist {
|
||||
color: #5a6572;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #5a6572;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
body {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.player {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
208
examples/play-from-disk-playlist-control/web/app.js
Normal file
208
examples/play-from-disk-playlist-control/web/app.js
Normal file
@@ -0,0 +1,208 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
let pc = null
|
||||
let playlistChannel = null
|
||||
let started = false
|
||||
|
||||
const logs = document.getElementById('logs')
|
||||
const nowPlayingEl = document.getElementById('nowPlaying')
|
||||
const playlistEl = document.getElementById('playlist')
|
||||
const startButton = document.getElementById('startButton')
|
||||
const audio = document.getElementById('remoteAudio')
|
||||
|
||||
const log = msg => {
|
||||
logs.innerHTML += `${msg}<br>`
|
||||
logs.scrollTop = logs.scrollHeight
|
||||
}
|
||||
|
||||
async function startSession () {
|
||||
if (started) {
|
||||
return
|
||||
}
|
||||
started = true
|
||||
startButton.disabled = true
|
||||
log('Creating PeerConnection...')
|
||||
|
||||
pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
})
|
||||
|
||||
pc.createDataChannel('sctp-bootstrap')
|
||||
pc.oniceconnectionstatechange = () => log(`ICE state: ${pc.iceConnectionState}`)
|
||||
pc.onconnectionstatechange = () => log(`Peer state: ${pc.connectionState}`)
|
||||
pc.ontrack = event => {
|
||||
audio.srcObject = event.streams[0]
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
pc.ondatachannel = event => {
|
||||
if (event.channel.label !== 'playlist') {
|
||||
return
|
||||
}
|
||||
playlistChannel = event.channel
|
||||
playlistChannel.onopen = () => log('playlist DataChannel open')
|
||||
playlistChannel.onclose = () => log('playlist DataChannel closed')
|
||||
playlistChannel.onmessage = e => handleMessage(e.data)
|
||||
}
|
||||
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
log(`Sending offer (${pc.localDescription.sdp.length} bytes)`)
|
||||
|
||||
const res = await fetch('/whep', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/sdp' },
|
||||
body: pc.localDescription.sdp
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`whep failed: ${res.status} ${body}`)
|
||||
}
|
||||
const answerSDP = await res.text()
|
||||
if (!answerSDP) {
|
||||
throw new Error('no SDP answer from server')
|
||||
}
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP })
|
||||
log('Answer applied. Waiting for media and playlist...')
|
||||
} catch (err) {
|
||||
log(`Error during negotiation: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
function sendPrev () {
|
||||
sendRawCommand('prev')
|
||||
}
|
||||
|
||||
function sendNext () {
|
||||
sendRawCommand('next')
|
||||
}
|
||||
|
||||
function sendList () {
|
||||
sendRawCommand('list')
|
||||
}
|
||||
|
||||
function sendCommand () {
|
||||
const value = document.getElementById('commandInput').value
|
||||
if (value.trim() === '') {
|
||||
return
|
||||
}
|
||||
sendRawCommand(value)
|
||||
}
|
||||
|
||||
function sendRawCommand (text) {
|
||||
if (!playlistChannel || playlistChannel.readyState !== 'open') {
|
||||
log('playlist channel not open yet')
|
||||
return
|
||||
}
|
||||
|
||||
playlistChannel.send(text)
|
||||
}
|
||||
|
||||
function handleMessage (data) {
|
||||
const lines = data.trim().split('\n')
|
||||
const playlist = []
|
||||
let current = null
|
||||
let now = null
|
||||
|
||||
lines.forEach(line => {
|
||||
const parts = line.split('|')
|
||||
if (parts.length === 0) {
|
||||
return
|
||||
}
|
||||
switch (parts[0]) {
|
||||
case 'playlist':
|
||||
current = Number(parts[1] || 0)
|
||||
break
|
||||
case 'track':
|
||||
playlist.push({
|
||||
index: Number(parts[1] || 0),
|
||||
serial: Number(parts[2] || 0),
|
||||
duration_ms: Number(parts[3] || 0),
|
||||
title: parts[4] || '',
|
||||
artist: parts[5] || ''
|
||||
})
|
||||
break
|
||||
case 'now':
|
||||
now = {
|
||||
index: Number(parts[1] || 0),
|
||||
serial: Number(parts[2] || 0),
|
||||
channels: Number(parts[3] || 0),
|
||||
sample_rate: Number(parts[4] || 0),
|
||||
duration_ms: Number(parts[5] || 0),
|
||||
title: parts[6] || '',
|
||||
artist: parts[7] || '',
|
||||
vendor: parts[8] || '',
|
||||
comments: (parts[9] || '').split(',').filter(Boolean).map(s => {
|
||||
const [k, v] = s.split('=')
|
||||
return { key: k, value: v }
|
||||
})
|
||||
}
|
||||
break
|
||||
default:
|
||||
log(`Message: ${line}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (playlist.length > 0) {
|
||||
renderPlaylist({ tracks: playlist, current })
|
||||
}
|
||||
if (now) {
|
||||
renderNowPlaying(now)
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlaylist (message) {
|
||||
playlistEl.innerHTML = ''
|
||||
message.tracks.forEach(track => {
|
||||
const li = document.createElement('li')
|
||||
li.innerText = `${track.index + 1}. ${track.title || '(untitled)'} — ${track.artist || 'unknown artist'} (${prettyDuration(track.duration_ms)})`
|
||||
if (track.index === message.current) {
|
||||
li.classList.add('current')
|
||||
}
|
||||
playlistEl.appendChild(li)
|
||||
})
|
||||
|
||||
if (message.hint) {
|
||||
log(message.hint)
|
||||
}
|
||||
}
|
||||
|
||||
function renderNowPlaying (track) {
|
||||
const title = track.title || '(untitled)'
|
||||
const artist = track.artist || 'unknown artist'
|
||||
const vendor = track.vendor ? `<div class="meta">Vendor: ${track.vendor}</div>` : ''
|
||||
const channels = track.channels || '?'
|
||||
const sampleRate = track.sample_rate || '?'
|
||||
const comments = (track.comments || []).map(c => `<div class="meta">${c.key}: ${c.value}</div>`).join('')
|
||||
|
||||
nowPlayingEl.innerHTML = `
|
||||
<div class="label">Now playing</div>
|
||||
<div class="track">${title}</div>
|
||||
<div class="artist">${artist}</div>
|
||||
<div class="meta">Serial: ${track.serial} | Channels: ${channels} | Sample rate: ${sampleRate}</div>
|
||||
<div class="meta">Duration: ${prettyDuration(track.duration_ms)}</div>
|
||||
${vendor}
|
||||
${comments}
|
||||
`
|
||||
}
|
||||
|
||||
function prettyDuration (ms) {
|
||||
if (!ms || ms < 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
const totalSeconds = Math.round(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
window.startSession = startSession
|
||||
window.sendPrev = sendPrev
|
||||
window.sendNext = sendNext
|
||||
window.sendList = sendList
|
||||
window.sendCommand = sendCommand
|
||||
45
examples/play-from-disk-playlist-control/web/index.html
Normal file
45
examples/play-from-disk-playlist-control/web/index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Ogg Playlist over SCTP</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h2>Ogg Playlist over RTP, control over SCTP</h2>
|
||||
<p>Server hosts both the WebRTC sender and this page. It streams Opus from <code>playlist.ogg</code>, shares metadata over a DataChannel, and lets you jump between tracks.</p>
|
||||
</header>
|
||||
|
||||
<section class="controls">
|
||||
<button id="startButton" onclick="startSession()">Start Session</button>
|
||||
<input id="commandInput" type="text" placeholder="next | prev | 1 | 2 ..." aria-label="Command">
|
||||
<button onclick="sendCommand()">Send</button>
|
||||
<button onclick="sendPrev()">Prev</button>
|
||||
<button onclick="sendNext()">Next</button>
|
||||
</section>
|
||||
|
||||
<section class="player">
|
||||
<audio id="remoteAudio" controls autoplay></audio>
|
||||
<div id="nowPlaying" class="card">Waiting for playlist...</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div>
|
||||
<h3>Playlist</h3>
|
||||
<ul id="playlist" class="card list"></ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Logs</h3>
|
||||
<div id="logs" class="card logs"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
examples/play-from-disk-renegotiation/README.md
Normal file
47
examples/play-from-disk-renegotiation/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# play-from-disk-renegotiation
|
||||
play-from-disk-renegotiation demonstrates Pion WebRTC's renegotiation abilities.
|
||||
|
||||
For a simpler example of playing a file from disk we also have [examples/play-from-disk](/examples/play-from-disk)
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download play-from-disk-renegotiation
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/play-from-disk-renegotiation
|
||||
```
|
||||
|
||||
### Create IVF named `output.ivf` that contains a VP8, VP9 or AV1 track
|
||||
|
||||
To encode video to VP8:
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -c:v libvpx -g 30 -b:v 2M output.ivf
|
||||
```
|
||||
|
||||
alternatively, to encode video to AV1 (Note: AV1 is CPU intensive, you may need to adjust `-cpu-used`):
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -c:v libaom-av1 -cpu-used 8 -g 30 -b:v 2M output.ivf
|
||||
```
|
||||
|
||||
Or to encode video to VP9:
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -c:v libvpx-vp9 -cpu-used 4 -g 30 -b:v 2M output.ivf
|
||||
```
|
||||
|
||||
If you have a VP8, VP9 or AV1 file in a different container you can use `ffmpeg` to mux it into IVF:
|
||||
```
|
||||
ffmpeg -i $INPUT_FILE -c:v copy -an output.ivf
|
||||
```
|
||||
|
||||
**Note**: In the `ffmpeg` command, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
|
||||
|
||||
### Run play-from-disk-renegotiation
|
||||
|
||||
The `output.ivf` you created should be in the same directory as `play-from-disk-renegotiation`. Execute `go run *.go`
|
||||
|
||||
### Open the Web UI
|
||||
Open [http://localhost:8080](http://localhost:8080) and you should have a `Add Track` and `Remove Track` button. Press these to add as many tracks as you want, or to remove as many as you wish.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
81
examples/play-from-disk-renegotiation/index.html
Normal file
81
examples/play-from-disk-renegotiation/index.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<title>play-from-disk-renegotiation</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button onclick="window.addVideo()"> Add Video </button><br />
|
||||
<button onclick="window.removeVideo()"> Remove Video </button><br />
|
||||
|
||||
|
||||
<h3> Video </h3>
|
||||
<div id="remoteVideos"></div> <br />
|
||||
|
||||
<h3> Logs </h3>
|
||||
<div id="logs"></div>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
let activeVideos = 0
|
||||
let pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}
|
||||
]
|
||||
})
|
||||
pc.ontrack = function (event) {
|
||||
var el = document.createElement(event.track.kind)
|
||||
el.srcObject = event.streams[0]
|
||||
el.autoplay = true
|
||||
el.controls = true
|
||||
|
||||
event.track.onmute = function(event) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
|
||||
document.getElementById('remoteVideos').appendChild(el)
|
||||
}
|
||||
|
||||
let doSignaling = method => {
|
||||
pc.createOffer()
|
||||
.then(offer => {
|
||||
pc.setLocalDescription(offer)
|
||||
|
||||
return fetch(`/${method}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(offer)
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => pc.setRemoteDescription(res))
|
||||
.catch(alert)
|
||||
}
|
||||
|
||||
// Create a noop DataChannel. By default PeerConnections do not connect
|
||||
// if they have no media tracks or DataChannels
|
||||
pc.createDataChannel('noop')
|
||||
doSignaling('createPeerConnection')
|
||||
|
||||
window.addVideo = () => {
|
||||
if (pc.getTransceivers().length <= activeVideos) {
|
||||
pc.addTransceiver('video')
|
||||
activeVideos++
|
||||
}
|
||||
|
||||
doSignaling('addVideo')
|
||||
};
|
||||
|
||||
window.removeVideo = () => {
|
||||
doSignaling('removeVideo')
|
||||
};
|
||||
</script>
|
||||
</html>
|
||||
215
examples/play-from-disk-renegotiation/main.go
Normal file
215
examples/play-from-disk-renegotiation/main.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// play-from-disk-renegotiation demonstrates Pion WebRTC's renegotiation abilities.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pion/randutil"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
)
|
||||
|
||||
var peerConnection *webrtc.PeerConnection //nolint
|
||||
|
||||
// doSignaling exchanges all state of the local PeerConnection and is called
|
||||
// every time a video is added or removed.
|
||||
func doSignaling(res http.ResponseWriter, req *http.Request) {
|
||||
var offer webrtc.SessionDescription
|
||||
if err := json.NewDecoder(req.Body).Decode(&offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
response, err := json.Marshal(*peerConnection.LocalDescription())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
res.Header().Set("Content-Type", "application/json")
|
||||
if _, err := res.Write(response); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a single video track.
|
||||
func createPeerConnection(res http.ResponseWriter, req *http.Request) {
|
||||
if peerConnection.ConnectionState() != webrtc.PeerConnectionStateNew {
|
||||
panic(fmt.Sprintf("createPeerConnection called in non-new state (%s)", peerConnection.ConnectionState()))
|
||||
}
|
||||
|
||||
doSignaling(res, req)
|
||||
fmt.Println("PeerConnection has been created")
|
||||
}
|
||||
|
||||
// Add a single video track.
|
||||
func addVideo(res http.ResponseWriter, req *http.Request) { //nolint:cyclop
|
||||
// Open a IVF file and start reading using our IVFReader
|
||||
file, err := os.Open("output.ivf")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
ivf, header, err := ivfreader.NewWith(file)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var mimeType string
|
||||
switch header.FourCC {
|
||||
case "VP80":
|
||||
mimeType = webrtc.MimeTypeVP8
|
||||
case "VP90":
|
||||
mimeType = webrtc.MimeTypeVP9
|
||||
case "AV01":
|
||||
mimeType = webrtc.MimeTypeAV1
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported codec: %s", header.FourCC))
|
||||
}
|
||||
|
||||
videoTrack, err := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: mimeType},
|
||||
fmt.Sprintf("video-%d", randutil.NewMathRandomGenerator().Uint32()),
|
||||
fmt.Sprintf("video-%d", randutil.NewMathRandomGenerator().Uint32()),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rtpSender, err := peerConnection.AddTrack(videoTrack)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
doSignaling(res, req)
|
||||
fmt.Println("Video track has been added")
|
||||
go writeVideoToTrack(ivf, header, videoTrack)
|
||||
}
|
||||
|
||||
// Remove a single sender.
|
||||
func removeVideo(res http.ResponseWriter, req *http.Request) {
|
||||
if senders := peerConnection.GetSenders(); len(senders) != 0 {
|
||||
if err := peerConnection.RemoveTrack(senders[0]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
doSignaling(res, req)
|
||||
fmt.Println("Video track has been removed")
|
||||
}
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
if peerConnection, err = webrtc.NewPeerConnection(webrtc.Configuration{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
http.Handle("/", http.FileServer(http.Dir(".")))
|
||||
http.HandleFunc("/createPeerConnection", createPeerConnection)
|
||||
http.HandleFunc("/addVideo", addVideo)
|
||||
http.HandleFunc("/removeVideo", removeVideo)
|
||||
|
||||
go func() {
|
||||
fmt.Println("Open http://localhost:8080 to access this demo")
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":8080", nil))
|
||||
}()
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// Read a video file from disk and write it to a webrtc.Track
|
||||
// When the video has been completely read this exits without error.
|
||||
func writeVideoToTrack(
|
||||
ivf *ivfreader.IVFReader, header *ivfreader.IVFFileHeader, track *webrtc.TrackLocalStaticSample,
|
||||
) {
|
||||
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
|
||||
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
|
||||
//
|
||||
// It is important to use a time.Ticker instead of time.Sleep because
|
||||
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
|
||||
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
|
||||
ticker := time.NewTicker(
|
||||
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
|
||||
)
|
||||
defer ticker.Stop()
|
||||
for ; true; <-ticker.C {
|
||||
frame, _, err := ivf.ParseNextFrame()
|
||||
if err != nil {
|
||||
fmt.Printf("Finish writing video track: %s ", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err = track.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
|
||||
fmt.Printf("Finish writing video track: %s ", err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
41
examples/play-from-disk/README.md
Normal file
41
examples/play-from-disk/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# play-from-disk
|
||||
play-from-disk demonstrates how to send video and/or audio to your browser from files saved to disk.
|
||||
|
||||
For an example of playing H264 from disk see [play-from-disk-h264](https://github.com/pion/example-webrtc-applications/tree/main/play-from-disk-h264)
|
||||
|
||||
## Instructions
|
||||
### Create IVF named `output.ivf` that contains a VP8/VP9/AV1 track and/or `output.ogg` that contains a Opus track
|
||||
```
|
||||
ffmpeg -i "$INPUT_FILE" -g 30 -b:v 2M output.ivf
|
||||
ffmpeg -i "$INPUT_FILE" -vn -c:a libopus -ac 2 -frame_duration 20 -page_duration 20000 -map_metadata -1 output.ogg
|
||||
```
|
||||
|
||||
**Note**: In the `ffmpeg` command which produces the .ivf file, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
|
||||
|
||||
### Download play-from-disk
|
||||
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/play-from-disk@latest
|
||||
```
|
||||
|
||||
### Open play-from-disk example page
|
||||
[jsfiddle.net](https://jsfiddle.net/8kup9mvn/) you should see two text-areas, 'Start Session' button and 'Copy browser SessionDescription to clipboard'
|
||||
|
||||
### Run play-from-disk with your browsers Session Description as stdin
|
||||
The `output.ivf` you created should be in the same directory as `play-from-disk`. In the jsfiddle press 'Copy browser Session Description to clipboard' or copy the base64 string manually.
|
||||
|
||||
Now use this value you just copied as the input to `play-from-disk`
|
||||
|
||||
#### Linux/macOS
|
||||
Run `echo $BROWSER_SDP | play-from-disk`
|
||||
#### Windows
|
||||
1. Paste the SessionDescription into a file.
|
||||
1. Run `play-from-disk < my_file`
|
||||
|
||||
### Input play-from-disk's Session Description into your browser
|
||||
Copy the text that `play-from-disk` just emitted and copy into the second text area in the jsfiddle
|
||||
|
||||
### Hit 'Start Session' in jsfiddle, enjoy your video!
|
||||
A video should start playing in your browser above the input boxes. `play-from-disk` will exit when the file reaches the end
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
8
examples/play-from-disk/jsfiddle/demo.css
Normal file
8
examples/play-from-disk/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/play-from-disk/jsfiddle/demo.details
Normal file
8
examples/play-from-disk/jsfiddle/demo.details
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: play-from-disk
|
||||
description: play-from-disk demonstrates how to send video to your browser from a file saved to disk.
|
||||
authors:
|
||||
- Sean DuBois
|
||||
30
examples/play-from-disk/jsfiddle/demo.html
Normal file
30
examples/play-from-disk/jsfiddle/demo.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
Browser Session Description
|
||||
<br/>
|
||||
<textarea id="localSessionDescription" readonly="true"></textarea>
|
||||
<br/>
|
||||
|
||||
<button onclick="window.copySessionDescription()">Copy browser Session Description to clipboard</button>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
Remote Session Description
|
||||
<br/>
|
||||
<textarea id="remoteSessionDescription"></textarea>
|
||||
<br/>
|
||||
<button onclick="window.startSession()">Start Session</button>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
Video
|
||||
<br/>
|
||||
<div id="remoteVideos"></div> <br />
|
||||
|
||||
Logs
|
||||
<br/>
|
||||
<div id="div"></div>
|
||||
67
examples/play-from-disk/jsfiddle/demo.js
Normal file
67
examples/play-from-disk/jsfiddle/demo.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}]
|
||||
})
|
||||
const log = msg => {
|
||||
document.getElementById('div').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
const el = document.createElement(event.track.kind)
|
||||
el.srcObject = event.streams[0]
|
||||
el.autoplay = true
|
||||
el.controls = true
|
||||
|
||||
document.getElementById('remoteVideos').appendChild(el)
|
||||
}
|
||||
|
||||
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
|
||||
pc.onicecandidate = event => {
|
||||
if (event.candidate === null) {
|
||||
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
|
||||
}
|
||||
}
|
||||
|
||||
// Offer to receive 1 audio, and 1 video track
|
||||
pc.addTransceiver('video', {
|
||||
direction: 'sendrecv'
|
||||
})
|
||||
pc.addTransceiver('audio', {
|
||||
direction: 'sendrecv'
|
||||
})
|
||||
|
||||
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
|
||||
|
||||
window.startSession = () => {
|
||||
const sd = document.getElementById('remoteSessionDescription').value
|
||||
if (sd === '') {
|
||||
return alert('Session Description must not be empty')
|
||||
}
|
||||
|
||||
try {
|
||||
pc.setRemoteDescription(JSON.parse(atob(sd)))
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}
|
||||
|
||||
window.copySessionDescription = () => {
|
||||
const browserSessionDescription = document.getElementById('localSessionDescription')
|
||||
|
||||
browserSessionDescription.focus()
|
||||
browserSessionDescription.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
const msg = successful ? 'successful' : 'unsuccessful'
|
||||
log('Copying SessionDescription was ' + msg)
|
||||
} catch (err) {
|
||||
log('Oops, unable to copy SessionDescription ' + err)
|
||||
}
|
||||
}
|
||||
335
examples/play-from-disk/main.go
Normal file
335
examples/play-from-disk/main.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// play-from-disk demonstrates how to send video and/or audio to your browser from files saved to disk.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
"github.com/pion/webrtc/v4/pkg/media/oggreader"
|
||||
)
|
||||
|
||||
const (
|
||||
audioFileName = "output.ogg"
|
||||
videoFileName = "output.ivf"
|
||||
oggPageDuration = time.Millisecond * 20
|
||||
)
|
||||
|
||||
func main() { //nolint:gocognit,cyclop,gocyclo,maintidx
|
||||
// Assert that we have an audio or video file
|
||||
_, err := os.Stat(videoFileName)
|
||||
haveVideoFile := !os.IsNotExist(err)
|
||||
|
||||
_, err = os.Stat(audioFileName)
|
||||
haveAudioFile := !os.IsNotExist(err)
|
||||
|
||||
if !haveAudioFile && !haveVideoFile {
|
||||
panic("Could not find `" + audioFileName + "` or `" + videoFileName + "`")
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
|
||||
|
||||
if haveVideoFile { //nolint:nestif
|
||||
file, openErr := os.Open(videoFileName)
|
||||
if openErr != nil {
|
||||
panic(openErr)
|
||||
}
|
||||
|
||||
_, header, openErr := ivfreader.NewWith(file)
|
||||
if openErr != nil {
|
||||
panic(openErr)
|
||||
}
|
||||
|
||||
// Determine video codec
|
||||
var trackCodec string
|
||||
switch header.FourCC {
|
||||
case "AV01":
|
||||
trackCodec = webrtc.MimeTypeAV1
|
||||
case "VP90":
|
||||
trackCodec = webrtc.MimeTypeVP9
|
||||
case "VP80":
|
||||
trackCodec = webrtc.MimeTypeVP8
|
||||
default:
|
||||
panic(fmt.Sprintf("Unable to handle FourCC %s", header.FourCC))
|
||||
}
|
||||
|
||||
// Create a video track
|
||||
videoTrack, videoTrackErr := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion",
|
||||
)
|
||||
if videoTrackErr != nil {
|
||||
panic(videoTrackErr)
|
||||
}
|
||||
|
||||
rtpSender, videoTrackErr := peerConnection.AddTrack(videoTrack)
|
||||
if videoTrackErr != nil {
|
||||
panic(videoTrackErr)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
// Open a IVF file and start reading using our IVFReader
|
||||
file, ivfErr := os.Open(videoFileName)
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
ivf, header, ivfErr := ivfreader.NewWith(file)
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
// Wait for connection established
|
||||
<-iceConnectedCtx.Done()
|
||||
|
||||
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
|
||||
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
|
||||
//
|
||||
// It is important to use a time.Ticker instead of time.Sleep because
|
||||
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
|
||||
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
|
||||
ticker := time.NewTicker(
|
||||
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
|
||||
)
|
||||
defer ticker.Stop()
|
||||
for ; true; <-ticker.C {
|
||||
frame, _, ivfErr := ivf.ParseNextFrame()
|
||||
if errors.Is(ivfErr, io.EOF) {
|
||||
fmt.Printf("All video frames parsed and sent")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
|
||||
if ivfErr = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); ivfErr != nil {
|
||||
panic(ivfErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if haveAudioFile { //nolint:nestif
|
||||
// Create a audio track
|
||||
audioTrack, audioTrackErr := webrtc.NewTrackLocalStaticSample(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion",
|
||||
)
|
||||
if audioTrackErr != nil {
|
||||
panic(audioTrackErr)
|
||||
}
|
||||
|
||||
rtpSender, audioTrackErr := peerConnection.AddTrack(audioTrack)
|
||||
if audioTrackErr != nil {
|
||||
panic(audioTrackErr)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
// Open a OGG file and start reading using our OGGReader
|
||||
file, oggErr := os.Open(audioFileName)
|
||||
if oggErr != nil {
|
||||
panic(oggErr)
|
||||
}
|
||||
|
||||
// Open on oggfile in non-checksum mode.
|
||||
ogg, _, oggErr := oggreader.NewWith(file)
|
||||
if oggErr != nil {
|
||||
panic(oggErr)
|
||||
}
|
||||
|
||||
// Wait for connection established
|
||||
<-iceConnectedCtx.Done()
|
||||
|
||||
// Keep track of last granule, the difference is the amount of samples in the buffer
|
||||
var lastGranule uint64
|
||||
|
||||
// It is important to use a time.Ticker instead of time.Sleep because
|
||||
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
|
||||
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
|
||||
ticker := time.NewTicker(oggPageDuration)
|
||||
defer ticker.Stop()
|
||||
for ; true; <-ticker.C {
|
||||
pageData, pageHeader, oggErr := ogg.ParseNextPage()
|
||||
if errors.Is(oggErr, io.EOF) {
|
||||
fmt.Printf("All audio pages parsed and sent")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if oggErr != nil {
|
||||
panic(oggErr)
|
||||
}
|
||||
|
||||
// The amount of samples is the difference between the last and current timestamp
|
||||
sampleCount := float64(pageHeader.GranulePosition - lastGranule)
|
||||
lastGranule = pageHeader.GranulePosition
|
||||
sampleDuration := time.Duration((sampleCount/48000)*1000) * time.Millisecond
|
||||
|
||||
if oggErr = audioTrack.WriteSample(media.Sample{Data: pageData, Duration: sampleDuration}); oggErr != nil {
|
||||
panic(oggErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("Connection State has changed %s \n", connectionState.String())
|
||||
if connectionState == webrtc.ICEConnectionStateConnected {
|
||||
iceConnectedCtxCancel()
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for the offer to be pasted
|
||||
offer := webrtc.SessionDescription{}
|
||||
decode(readUntilNewline(), &offer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
if err = peerConnection.SetRemoteDescription(offer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the answer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
48
examples/quick-switch/README.md
Normal file
48
examples/quick-switch/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# quick-switch
|
||||
quick-switch demonstrates how to quickly switch between multiple videos using WebRTC.
|
||||
Similiar to how sites like TikTok quickly swipe between videos
|
||||
|
||||
|
||||
<video src="https://github.com/user-attachments/assets/07f15044-3aeb-44e2-a866-14bd4258aca0" autoplay loop muted> </video>
|
||||
|
||||
The logic in the frontend is purposefully kept as simple as possible. Making it easy to use this on any platform that supports WebRTC.
|
||||
|
||||
In the `main.go` we have one video track, and we switch which video file is written to it.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download quick-switch
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/quick-switch
|
||||
```
|
||||
|
||||
### Create your videos
|
||||
This example expects AV1 inside a ivf container. I used the follow to encode for my demo.
|
||||
|
||||
```
|
||||
ffmpeg -y \
|
||||
-i $YOUR_INPUT_VIDEO \
|
||||
-c:v libaom-av1 \
|
||||
-usage realtime \
|
||||
-lag-in-frames 0 \
|
||||
-crf 30 \
|
||||
-b:v 0 \
|
||||
-g 15 \
|
||||
-keyint_min 15 \
|
||||
-sc_threshold 0 \
|
||||
-pix_fmt yuv420p \
|
||||
-f ivf \
|
||||
output.ivf
|
||||
```
|
||||
|
||||
|
||||
### Run quick-switch
|
||||
Execute `go run *.go`
|
||||
|
||||
### Open the Web UI
|
||||
Open [http://localhost:8080](http://localhost:8080). This will automatically start a PeerConnection.
|
||||
|
||||
Press 'Next Video' and have fun! If you have ideas on how to make it better we would love to hear.
|
||||
45
examples/quick-switch/index.html
Normal file
45
examples/quick-switch/index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<html>
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
|
||||
<head>
|
||||
<title>quick-switch</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<video id="video" autoplay muted playsinline width="500" height="500"></video>
|
||||
<button id="nextVideo"> Next Video </button><br />
|
||||
</body>
|
||||
|
||||
<script>
|
||||
let peerConnection = new RTCPeerConnection()
|
||||
peerConnection.addTransceiver('video', {direction: 'recvonly'})
|
||||
peerConnection.ontrack = function (event) {
|
||||
document.getElementById('video').srcObject = event.streams[0]
|
||||
}
|
||||
|
||||
let dataChannel = peerConnection.createDataChannel('')
|
||||
dataChannel.onopen = () => {
|
||||
document.getElementById('nextVideo').onclick = () => {
|
||||
dataChannel.send('')
|
||||
}
|
||||
}
|
||||
|
||||
peerConnection.createOffer()
|
||||
.then(offer => peerConnection.setLocalDescription(offer)
|
||||
.then(() => fetch(`/whip`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp'
|
||||
},
|
||||
body: offer.sdp
|
||||
}))
|
||||
)
|
||||
.then(res => res.text())
|
||||
.then(res => peerConnection.setRemoteDescription({type: 'answer', sdp: res}))
|
||||
.catch(alert)
|
||||
</script>
|
||||
|
||||
</html>
|
||||
188
examples/quick-switch/main.go
Normal file
188
examples/quick-switch/main.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// quick-switch demonstrates Pion WebRTC's ability to quickly switch between videos.
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
|
||||
)
|
||||
|
||||
// nolint: gochecknoglobals
|
||||
var (
|
||||
tracksLock sync.RWMutex
|
||||
tracks []*webrtc.TrackLocalStaticSample
|
||||
|
||||
videoFiles []string
|
||||
videoFileIndex atomic.Int32
|
||||
)
|
||||
|
||||
func nextVideo() {
|
||||
newIndex := videoFileIndex.Load() + 1
|
||||
if int(newIndex) >= len(videoFiles) {
|
||||
newIndex = 0
|
||||
}
|
||||
|
||||
videoFileIndex.Store(newIndex)
|
||||
}
|
||||
|
||||
// nolint: cyclop
|
||||
func doWHIP(res http.ResponseWriter, req *http.Request) {
|
||||
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
|
||||
d.OnMessage(func(_ webrtc.DataChannelMessage) {
|
||||
nextVideo()
|
||||
})
|
||||
})
|
||||
|
||||
// One Track is used for PeerConnection. All video streams are written to one Track
|
||||
videoTrack, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeAV1,
|
||||
}, "video", "video")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if _, err = peerConnection.AddTrack(videoTrack); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
tracksLock.Lock()
|
||||
tracks = append(tracks, videoTrack)
|
||||
tracksLock.Unlock()
|
||||
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
|
||||
if connectionState == webrtc.ICEConnectionStateClosed || connectionState == webrtc.ICEConnectionStateFailed {
|
||||
if closeErr := peerConnection.Close(); closeErr != nil {
|
||||
panic(closeErr)
|
||||
}
|
||||
|
||||
tracksLock.Lock()
|
||||
tracks = slices.DeleteFunc(tracks, func(x *webrtc.TrackLocalStaticSample) bool { return x == videoTrack })
|
||||
tracksLock.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
offer, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = peerConnection.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer,
|
||||
SDP: string(offer),
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
res.Header().Set("Content-Type", "application/sdp")
|
||||
if _, err := res.Write([]byte(peerConnection.LocalDescription().SDP)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func playFile(fileIndex int32) {
|
||||
file, err := os.Open(videoFiles[fileIndex])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close() // nolint: errcheck
|
||||
|
||||
ivf, header, err := ivfreader.NewWith(file)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
frameDuration := time.Duration(header.TimebaseNumerator) * time.Second / time.Duration(header.TimebaseDenominator)
|
||||
ticker := time.NewTicker(frameDuration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if fileIndex != videoFileIndex.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
frame, _, err := ivf.ParseNextFrame()
|
||||
if errors.Is(err, io.EOF) {
|
||||
nextVideo()
|
||||
|
||||
return
|
||||
} else if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
tracksLock.RLock()
|
||||
for _, t := range tracks {
|
||||
if err = t.WriteSample(media.Sample{Data: frame, Duration: frameDuration}); err != nil {
|
||||
tracksLock.RUnlock()
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
tracksLock.RUnlock()
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.Handle("/", http.FileServer(http.Dir(".")))
|
||||
http.HandleFunc("/whip", doWHIP)
|
||||
|
||||
// Switch between all ivf files in current directory
|
||||
go func() {
|
||||
files, err := filepath.Glob("*.ivf")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, p := range files {
|
||||
videoFiles = append(videoFiles, filepath.Base(p))
|
||||
}
|
||||
if len(videoFiles) == 0 {
|
||||
panic("no .ivf files found in the working directory")
|
||||
}
|
||||
|
||||
for {
|
||||
playFile(videoFileIndex.Load())
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Println("Open http://localhost:8080 to access this demo")
|
||||
// nolint: gosec
|
||||
panic(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
29
examples/reflect/README.md
Normal file
29
examples/reflect/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# reflect
|
||||
reflect demonstrates how with one PeerConnection you can send video to Pion and have the packets sent back. This example could be easily extended to do server side processing.
|
||||
|
||||
## Instructions
|
||||
### Download reflect
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/reflect@latest
|
||||
```
|
||||
|
||||
### Open reflect example page
|
||||
[jsfiddle.net](https://jsfiddle.net/g643ft1k/) you should see two text-areas and a 'Start Session' button.
|
||||
|
||||
### Run reflect, with your browsers SessionDescription as stdin
|
||||
In the jsfiddle the top textarea is your browser's Session Description. Press `Copy browser SDP to clipboard` or copy the base64 string manually.
|
||||
We will use this value in the next step.
|
||||
|
||||
#### Linux/macOS
|
||||
Run `echo $BROWSER_SDP | reflect`
|
||||
#### Windows
|
||||
1. Paste the SessionDescription into a file.
|
||||
1. Run `reflect < my_file`
|
||||
|
||||
### Input reflect's SessionDescription into your browser
|
||||
Copy the text that `reflect` just emitted and copy into second text area
|
||||
|
||||
### Hit 'Start Session' in jsfiddle, enjoy your video!
|
||||
Your browser should send video to Pion, and then it will be relayed right back to you.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
8
examples/reflect/jsfiddle/demo.css
Normal file
8
examples/reflect/jsfiddle/demo.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
*/
|
||||
textarea {
|
||||
width: 500px;
|
||||
min-height: 75px;
|
||||
}
|
||||
8
examples/reflect/jsfiddle/demo.details
Normal file
8
examples/reflect/jsfiddle/demo.details
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: reflect
|
||||
description: Example of how to have Pion send back to the user exactly what it receives using the same PeerConnection.
|
||||
authors:
|
||||
- Sean DuBois
|
||||
23
examples/reflect/jsfiddle/demo.html
Normal file
23
examples/reflect/jsfiddle/demo.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
Browser base64 Session Description<br />
|
||||
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
|
||||
<button onclick="window.copySDP()">
|
||||
Copy browser SDP to clipboard
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
Golang base64 Session Description<br />
|
||||
<textarea id="remoteSessionDescription"></textarea> <br/>
|
||||
<button onclick="window.startSession()"> Start Session </button><br />
|
||||
|
||||
<br />
|
||||
|
||||
Video<br />
|
||||
<div id="remoteVideos"></div> <br />
|
||||
|
||||
Logs<br />
|
||||
<div id="logs"></div>
|
||||
64
examples/reflect/jsfiddle/demo.js
Normal file
64
examples/reflect/jsfiddle/demo.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}
|
||||
]
|
||||
})
|
||||
const log = msg => {
|
||||
document.getElementById('logs').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
||||
.then(stream => {
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream))
|
||||
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
|
||||
}).catch(log)
|
||||
|
||||
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
|
||||
pc.onicecandidate = event => {
|
||||
if (event.candidate === null) {
|
||||
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
|
||||
}
|
||||
}
|
||||
pc.ontrack = function (event) {
|
||||
const el = document.createElement(event.track.kind)
|
||||
el.srcObject = event.streams[0]
|
||||
el.autoplay = true
|
||||
el.controls = true
|
||||
|
||||
document.getElementById('remoteVideos').appendChild(el)
|
||||
}
|
||||
|
||||
window.startSession = () => {
|
||||
const sd = document.getElementById('remoteSessionDescription').value
|
||||
if (sd === '') {
|
||||
return alert('Session Description must not be empty')
|
||||
}
|
||||
|
||||
try {
|
||||
pc.setRemoteDescription(JSON.parse(atob(sd)))
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
}
|
||||
}
|
||||
|
||||
window.copySDP = () => {
|
||||
const browserSDP = document.getElementById('localSessionDescription')
|
||||
|
||||
browserSDP.focus()
|
||||
browserSDP.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
const msg = successful ? 'successful' : 'unsuccessful'
|
||||
log('Copying SDP was ' + msg)
|
||||
} catch (err) {
|
||||
log('Unable to copy SDP ' + err)
|
||||
}
|
||||
}
|
||||
226
examples/reflect/main.go
Normal file
226
examples/reflect/main.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
|
||||
// reflect demonstrates how with one PeerConnection you can send video to Pion and have the packets sent back
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/interceptor/pkg/intervalpli"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint:gocognit, cyclop
|
||||
func main() {
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
|
||||
// Create a MediaEngine object to configure the supported codec
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
|
||||
// Setup the codecs you want to use.
|
||||
// We'll use a VP8 and Opus but you can also define your own
|
||||
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
|
||||
RTPCodecCapability: webrtc.RTPCodecCapability{
|
||||
MimeType: webrtc.MimeTypeVP8, ClockRate: 90000, Channels: 0, SDPFmtpLine: "", RTCPFeedback: nil,
|
||||
},
|
||||
PayloadType: 96,
|
||||
}, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a InterceptorRegistry. This is the user configurable RTP/RTCP Pipeline.
|
||||
// This provides NACKs, RTCP Reports and other features. If you use `webrtc.NewPeerConnection`
|
||||
// this is enabled by default. If you are manually managing You MUST create a InterceptorRegistry
|
||||
// for each PeerConnection.
|
||||
interceptorRegistry := &interceptor.Registry{}
|
||||
|
||||
// Use the default set of Interceptors
|
||||
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Register a intervalpli factory
|
||||
// This interceptor sends a PLI every 3 seconds. A PLI causes a video keyframe to be generated by the sender.
|
||||
// This makes our video seekable and more error resilent, but at a cost of lower picture quality and higher bitrates
|
||||
// A real world application should process incoming RTCP packets from viewers and forward them to senders
|
||||
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
interceptorRegistry.Add(intervalPliFactory)
|
||||
|
||||
// Create the API object with the MediaEngine
|
||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine), webrtc.WithInterceptorRegistry(interceptorRegistry))
|
||||
|
||||
// Prepare the configuration
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := api.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer func() {
|
||||
if cErr := peerConnection.Close(); cErr != nil {
|
||||
fmt.Printf("cannot close peerConnection: %v\n", cErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create Track that we send video back to browser on
|
||||
outputTrack, err := webrtc.NewTrackLocalStaticRTP(
|
||||
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "pion",
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Add this newly created track to the PeerConnection
|
||||
rtpSender, err := peerConnection.AddTrack(outputTrack)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Read incoming RTCP packets
|
||||
// Before these packets are returned they are processed by interceptors. For things
|
||||
// like NACK this needs to be called.
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the offer to be pasted
|
||||
offer := webrtc.SessionDescription{}
|
||||
decode(readUntilNewline(), &offer)
|
||||
|
||||
// Set the remote SessionDescription
|
||||
err = peerConnection.SetRemoteDescription(offer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set a handler for when a new remote track starts, this handler copies inbound RTP packets,
|
||||
// replaces the SSRC and sends them back
|
||||
peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { //nolint: revive
|
||||
fmt.Printf("Track has started, of type %d: %s \n", track.PayloadType(), track.Codec().MimeType)
|
||||
for {
|
||||
// Read RTP packets being sent to Pion
|
||||
rtp, _, readErr := track.ReadRTP()
|
||||
if readErr != nil {
|
||||
panic(readErr)
|
||||
}
|
||||
|
||||
if writeErr := outputTrack.WriteRTP(rtp); writeErr != nil {
|
||||
panic(writeErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set the handler for Peer connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
|
||||
|
||||
if state == webrtc.PeerConnectionStateFailed {
|
||||
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
|
||||
// It may be reconnected using an ICE Restart.
|
||||
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
|
||||
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
|
||||
fmt.Println("Peer Connection has gone to failed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if state == webrtc.PeerConnectionStateClosed {
|
||||
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
|
||||
fmt.Println("Peer Connection has gone to closed exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Create an answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// Output the answer in base64 so we can paste it in browser
|
||||
fmt.Println(encode(peerConnection.LocalDescription()))
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
// Read from stdin until we get a newline.
|
||||
func readUntilNewline() (in string) {
|
||||
var err error
|
||||
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
in, err = r.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if in = strings.TrimSpace(in); len(in) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// JSON encode + base64 a SessionDescription.
|
||||
func encode(obj *webrtc.SessionDescription) string {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Decode a base64 and unmarshal JSON into a SessionDescription.
|
||||
func decode(in string, obj *webrtc.SessionDescription) {
|
||||
b, err := base64.StdEncoding.DecodeString(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(b, obj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
25
examples/repacketize/README.md
Normal file
25
examples/repacketize/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# repacketize
|
||||
|
||||
repacketize demonstrates how many video codecs can be received, depacketized and packetized by Pion over RTP.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download and run repacketize
|
||||
|
||||
```
|
||||
go install github.com/pion/webrtc/v4/examples/repacketize@latest
|
||||
```
|
||||
|
||||
### Open repacketize local page
|
||||
|
||||
[localhost:8080](http://localhost:8080/) you should see a dropdown selector and a "Start" button.
|
||||
|
||||
### Select one of the video codecs
|
||||
|
||||
Availability of codecs depends on the browser you are accessing the page from; Safari and Google Chrome on Windows should support all of them.
|
||||
|
||||
### Hit 'Start', enjoy your video
|
||||
|
||||
Your browser should send video to Pion, and then it will be relayed right back to you.
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool.
|
||||
28
examples/repacketize/index.html
Normal file
28
examples/repacketize/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Pion - Repacketize Example</title>
|
||||
<script src="index.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<label for="codec">Codec: </label>
|
||||
<select id="codec">
|
||||
<option value="H264" selected>H.264</option>
|
||||
<option value="H265">H.265</option>
|
||||
<option value="VP8">VP8</option>
|
||||
<option value="VP9">VP9</option>
|
||||
<option value="AV1">AV1</option>
|
||||
</select>
|
||||
<button id="start">Start</button>
|
||||
<br />
|
||||
<video id="screen" autoplay hidden></video>
|
||||
<video id="received" autoplay hidden></video>
|
||||
<br />
|
||||
<div id="logs"></div>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user