From c7c13cc485cbbb83f8275b108e45f33a72688cbf Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Fri, 24 Jul 2026 22:13:52 -0500 Subject: [PATCH] Propagate and apply rendition selection to WebRTC and HLS - Pass selected rendition to WebRTC hooks to support quality switching - Update HLS playlist RPC to support direct audio track requests via rendition parameter - Add PrimaryAudioTrackID helper to livehls to resolve audio-only streams Signed-off-by: Natalie Bridgers --- .../src/components/mobile-player/shared.tsx | 9 ++++++--- .../components/mobile-player/use-webrtc.tsx | 9 +++++++-- .../mobile-player/video-async.native.tsx | 2 +- .../src/components/mobile-player/video.tsx | 3 ++- pkg/livehls/livehls.go | 20 +++++++++++++++++++ pkg/livehls/livehls_test.go | 11 ++++++++++ pkg/spxrpc/place_stream_playback_getlive.go | 11 ++++++++++ 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/js/components/src/components/mobile-player/shared.tsx b/js/components/src/components/mobile-player/shared.tsx index 12991c4d0..ee7d4b0ec 100644 --- a/js/components/src/components/mobile-player/shared.tsx +++ b/js/components/src/components/mobile-player/shared.tsx @@ -45,10 +45,13 @@ export function srcToUrl( } let outUrl: string; if (protocol === PlayerProtocol.HLS) { - if (props.selectedRendition === "auto") { - outUrl = `${url}/xrpc/place.stream.playback.getLivePlaylist?streamer=${props.src}`; + if ( + props.selectedRendition && + props.selectedRendition !== "auto" && + props.selectedRendition !== "source" + ) { + outUrl = `${url}/xrpc/place.stream.playback.getLivePlaylist?streamer=${props.src}&rendition=${props.selectedRendition}`; } else { - // todo: re-implement track selection here outUrl = `${url}/xrpc/place.stream.playback.getLivePlaylist?streamer=${props.src}`; } } else if (protocol === PlayerProtocol.PROGRESSIVE_MP4) { diff --git a/js/components/src/components/mobile-player/use-webrtc.tsx b/js/components/src/components/mobile-player/use-webrtc.tsx index af2fc8890..2f79b7bf0 100644 --- a/js/components/src/components/mobile-player/use-webrtc.tsx +++ b/js/components/src/components/mobile-player/use-webrtc.tsx @@ -14,6 +14,7 @@ import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives"; export default function useWebRTC( streamer: string, + rendition: string, ): [MediaStream | null, boolean] { const [mediaStream, setMediaStream] = useState(null); const [stuck, setStuck] = useState(false); @@ -73,6 +74,7 @@ export default function useWebRTC( agent, isOwnStream, playbackWorkerUrl, + rendition, ); }); @@ -111,7 +113,7 @@ export default function useWebRTC( clearInterval(handle); peerConnection.close(); }; - }, [streamer, agent, isOwnStream, playbackWorkerUrl]); + }, [streamer, agent, isOwnStream, playbackWorkerUrl, rendition]); return [mediaStream, stuck]; } @@ -134,6 +136,7 @@ export async function negotiateConnectionWithClientOffer( agent?: StreamplaceAgent, isOwnStream?: boolean, playbackWorkerUrl?: string | null, + rendition?: string, ) { /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ const offer = await peerConnection.createOffer({ @@ -173,6 +176,7 @@ export async function negotiateConnectionWithClientOffer( agent, isOwnStream, playbackWorkerUrl, + rendition, ); let text = new TextDecoder().decode(response); if ((peerConnection.connectionState as string) === "closed") { @@ -285,6 +289,7 @@ async function postSDPOffer( agent?: StreamplaceAgent, isOwnStream?: boolean, playbackWorkerUrl?: string | null, + rendition?: string, ) { if (!agent) { throw new Error("No agent found"); @@ -300,7 +305,7 @@ async function postSDPOffer( data as any, { params: { - rendition: "source", + rendition: rendition || "source", streamer: streamer, }, }, diff --git a/js/components/src/components/mobile-player/video-async.native.tsx b/js/components/src/components/mobile-player/video-async.native.tsx index 071b93bad..6a4749c95 100644 --- a/js/components/src/components/mobile-player/video-async.native.tsx +++ b/js/components/src/components/mobile-player/video-async.native.tsx @@ -252,7 +252,7 @@ export function NativeWHEP(props?: { }) { const selectedRendition = usePlayerStore((x) => x.selectedRendition); const src = usePlayerStore((x) => x.src); - const [stream, stuck] = useWebRTC(src); + const [stream, stuck] = useWebRTC(src, selectedRendition); const status = usePlayerStore((x) => x.status); const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth); diff --git a/js/components/src/components/mobile-player/video.tsx b/js/components/src/components/mobile-player/video.tsx index 95ce73362..4926fdd9c 100644 --- a/js/components/src/components/mobile-player/video.tsx +++ b/js/components/src/components/mobile-player/video.tsx @@ -515,11 +515,12 @@ export function WebRTCPlayerInner({ const status = usePlayerStore((x) => x.status); const setStatus = usePlayerStore((x) => x.setStatus); const src = usePlayerStore((x) => x.src); + const selectedRendition = usePlayerStore((x) => x.selectedRendition); const playerEvent = usePlayerStore((x) => x.playerEvent); const spurl = useStreamplaceStore((x) => x.url); - const [mediaStream, stuck] = useWebRTC(src); + const [mediaStream, stuck] = useWebRTC(src, selectedRendition); useEffect(() => { if (stuck) { diff --git a/pkg/livehls/livehls.go b/pkg/livehls/livehls.go index 2cdf1218b..cc3197331 100644 --- a/pkg/livehls/livehls.go +++ b/pkg/livehls/livehls.go @@ -255,6 +255,26 @@ func (w *Writer) SegmentData(trackID string, seq uint64) []byte { return nil } +// PrimaryAudioTrackID returns the track ID of the primary audio track, +// preferring AAC (broadest HLS support) over other codecs. Returns "" +// if no audio track exists. The selection mirrors MasterPlaylist's logic. +func (w *Writer) PrimaryAudioTrackID() string { + w.mu.Lock() + defer w.mu.Unlock() + primaryAudio := "" + for _, tid := range w.order { + if t := w.tracks[tid]; t != nil && t.Type == "audio" { + if primaryAudio == "" { + primaryAudio = tid + } + if strings.HasPrefix(t.Codec, "mp4a") { + return tid + } + } + } + return primaryAudio +} + // MediaPlaylist renders the live HLS media playlist for trackID. initURL is // the EXT-X-MAP target (the per-track init); segURI maps a segment's // media-sequence number to its URI. Returns "" for an unknown track. diff --git a/pkg/livehls/livehls_test.go b/pkg/livehls/livehls_test.go index a79c4dfd6..1d64de781 100644 --- a/pkg/livehls/livehls_test.go +++ b/pkg/livehls/livehls_test.go @@ -193,3 +193,14 @@ func TestMasterPlaylist(t *testing.T) { } } } + +func TestPrimaryAudioTrackID(t *testing.T) { + w := NewWriter() + _ = w.Observe(initEvent()) + _ = w.Observe(segEvent(bytes.Repeat([]byte{1}, 100), bytes.Repeat([]byte{2}, 40))) + + got := w.PrimaryAudioTrackID() + if got != "2" { + t.Errorf("PrimaryAudioTrackID() = %q, want %q", got, "2") + } +} diff --git a/pkg/spxrpc/place_stream_playback_getlive.go b/pkg/spxrpc/place_stream_playback_getlive.go index f1e116ea6..d41408101 100644 --- a/pkg/spxrpc/place_stream_playback_getlive.go +++ b/pkg/spxrpc/place_stream_playback_getlive.go @@ -84,6 +84,17 @@ func (s *Server) HandleGetLivePlaylist(c echo.Context) error { // Sub-playlist + segment URLs carry the resolved DID, so follow-up requests // skip handle resolution and stay stable across a session. track := c.QueryParam("track") + rendition := c.QueryParam("rendition") + + // rendition=audio requests the primary audio track's media playlist + // directly, skipping the master playlist so the player never loads video. + if track == "" && rendition == "audio" { + track = w.PrimaryAudioTrackID() + if track == "" { + return echo.NewHTTPError(http.StatusNotFound, "NoAudioTrack") + } + } + var body string if track == "" { body = w.MasterPlaylist(func(tid string) string { -- 2.51.2