mirror of
https://github.com/pion/webrtc.git
synced 2025-12-24 11:51:03 +08:00
Add playlist control example
This commit is contained in:
@@ -41,6 +41,12 @@
|
||||
"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",
|
||||
|
||||
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.
|
||||
542
examples/play-from-disk-playlist-control/main.go
Normal file
542
examples/play-from-disk-playlist-control/main.go
Normal file
@@ -0,0 +1,542 @@
|
||||
// SPDX-FileCopyrightText: 2024 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
// +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: 2024 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: 2024 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: 2024 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>
|
||||
Reference in New Issue
Block a user