diff --git a/examples/examples.json b/examples/examples.json index 49b34664..3b963d68 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -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", diff --git a/examples/play-from-disk-playlist-control/README.md b/examples/play-from-disk-playlist-control/README.md new file mode 100644 index 00000000..16b8586c --- /dev/null +++ b/examples/play-from-disk-playlist-control/README.md @@ -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. diff --git a/examples/play-from-disk-playlist-control/main.go b/examples/play-from-disk-playlist-control/main.go new file mode 100644 index 00000000..dc5a4859 --- /dev/null +++ b/examples/play-from-disk-playlist-control/main.go @@ -0,0 +1,542 @@ +// SPDX-FileCopyrightText: 2024 The Pion community +// 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, "|", "/") +} diff --git a/examples/play-from-disk-playlist-control/web/app.css b/examples/play-from-disk-playlist-control/web/app.css new file mode 100644 index 00000000..d221730a --- /dev/null +++ b/examples/play-from-disk-playlist-control/web/app.css @@ -0,0 +1,144 @@ +/* SPDX-FileCopyrightText: 2024 The Pion community + * 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%; + } +} diff --git a/examples/play-from-disk-playlist-control/web/app.js b/examples/play-from-disk-playlist-control/web/app.js new file mode 100644 index 00000000..9c3ddf26 --- /dev/null +++ b/examples/play-from-disk-playlist-control/web/app.js @@ -0,0 +1,208 @@ +/* eslint-env browser */ + +// SPDX-FileCopyrightText: 2024 The Pion community +// 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}
` + 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 ? `
Vendor: ${track.vendor}
` : '' + const channels = track.channels || '?' + const sampleRate = track.sample_rate || '?' + const comments = (track.comments || []).map(c => `
${c.key}: ${c.value}
`).join('') + + nowPlayingEl.innerHTML = ` +
Now playing
+
${title}
+
${artist}
+
Serial: ${track.serial} | Channels: ${channels} | Sample rate: ${sampleRate}
+
Duration: ${prettyDuration(track.duration_ms)}
+ ${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 diff --git a/examples/play-from-disk-playlist-control/web/index.html b/examples/play-from-disk-playlist-control/web/index.html new file mode 100644 index 00000000..c0c0d7d0 --- /dev/null +++ b/examples/play-from-disk-playlist-control/web/index.html @@ -0,0 +1,45 @@ + + + + + + + Ogg Playlist over SCTP + + + +
+

Ogg Playlist over RTP, control over SCTP

+

Server hosts both the WebRTC sender and this page. It streams Opus from playlist.ogg, shares metadata over a DataChannel, and lets you jump between tracks.

+
+ +
+ + + + + +
+ +
+ +
Waiting for playlist...
+
+ +
+
+

Playlist

+
    +
    +
    +

    Logs

    +
    +
    +
    + + + +