fix: use LiveKit track.attach() for adaptive stream and switch screen share to VP9 single-layer

Camera was stuck at 180p because our custom <video> rendering bypassed
LiveKit's adaptive stream observer. Replaced manual srcObject binding
with track.attach()/detach() in VoiceUser, StreamTile, and PictureInPicture
so the SFU receives viewport dimensions and forwards the correct
H.264 simulcast layer.

Screen share VP9 SVC with L3T3 spatial layers failed because hardware
VP9 encoders (NVENC, QSV, VCE) don't support spatial scalability —
Chrome silently degrades to L1T1. Reverted to VP9 single-layer
(simulcast: false, no scalabilityMode). Also targets encodings[length-1]
in applyOverdrive() for correct simulcast layer targeting.
This commit is contained in:
Jannis Braun
2026-02-26 02:33:18 +01:00
parent 33e9198de6
commit 4bb69851d3
5 changed files with 55 additions and 24 deletions
@@ -122,17 +122,26 @@ export function PictureInPicture() {
return 'Call'; return 'Call';
}, [currentVoiceChannelId, channels]); }, [currentVoiceChannelId, channels]);
// Video track attachment // Derive the LiveKit Track from the selected stream's participant
const lkTrack = selectedStream
? (selectedStream.type === 'screen'
? selectedStream.participant.lkScreenTrack
: selectedStream.participant.lkVideoTrack)
: null;
// Video track attachment — use LiveKit's track.attach() to register the element
// with the adaptive stream observer (enables SFU layer switching by viewport size)
// shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before) // shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before)
useEffect(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) return; if (!videoEl) return;
if (selectedStream?.track) { if (lkTrack) {
videoEl.srcObject = new MediaStream([selectedStream.track]); lkTrack.attach(videoEl);
return () => { lkTrack.detach(videoEl); };
} else { } else {
videoEl.srcObject = null; videoEl.srcObject = null;
} }
}, [selectedStream?.track, shouldShow]); }, [lkTrack, shouldShow]);
// Initialize position to bottom-right // Initialize position to bottom-right
useEffect(() => { useEffect(() => {
@@ -29,6 +29,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const isStreamMuted = streamMutes.get(userId) ?? false; const isStreamMuted = streamMutes.get(userId) ?? false;
const liveScreenTrack = tile.screenTrack?.readyState === 'live' ? tile.screenTrack : null; const liveScreenTrack = tile.screenTrack?.readyState === 'live' ? tile.screenTrack : null;
const liveLkScreenTrack = liveScreenTrack ? tile.lkScreenTrack : null;
// Quality badge state // Quality badge state
const [qualityBadge, setQualityBadge] = useState<string>(''); const [qualityBadge, setQualityBadge] = useState<string>('');
@@ -37,16 +38,18 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
// --- VIDEO --- // --- VIDEO --- use LiveKit's track.attach() to register the element
// with the adaptive stream observer (enables SFU layer switching by viewport size)
useEffect(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) return; if (!videoEl) return;
if (liveScreenTrack) { if (liveLkScreenTrack) {
videoEl.srcObject = new MediaStream([liveScreenTrack]); liveLkScreenTrack.attach(videoEl);
return () => { liveLkScreenTrack.detach(videoEl); };
} else { } else {
videoEl.srcObject = null; videoEl.srcObject = null;
} }
}, [liveScreenTrack]); }, [liveLkScreenTrack]);
// Quality badge (poll every 3s) // Quality badge (poll every 3s)
useEffect(() => { useEffect(() => {
@@ -34,16 +34,19 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
return () => tile.videoTrack?.removeEventListener('ended', onEnded); return () => tile.videoTrack?.removeEventListener('ended', onEnded);
}, [tile.videoTrack]); }, [tile.videoTrack]);
// Attach Video // Attach Video — use LiveKit's track.attach() to register the element
// with the adaptive stream observer (enables SFU layer switching by viewport size)
useEffect(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) return; if (!videoEl) return;
if (activeVideoTrack) { const lkTrack = tile.lkVideoTrack;
videoEl.srcObject = new MediaStream([activeVideoTrack]); if (lkTrack) {
lkTrack.attach(videoEl);
return () => { lkTrack.detach(videoEl); };
} else { } else {
videoEl.srcObject = null; videoEl.srcObject = null;
} }
}, [activeVideoTrack]); }, [tile.lkVideoTrack]);
// Context Menu // Context Menu
const [volumeMenu, setVolumeMenu] = useState<{ const [volumeMenu, setVolumeMenu] = useState<{
+14 -3
View File
@@ -46,6 +46,8 @@ export interface ParticipantInfo {
videoTrack: MediaStreamTrack | null; videoTrack: MediaStreamTrack | null;
screenTrack: MediaStreamTrack | null; screenTrack: MediaStreamTrack | null;
screenAudioTrack: MediaStreamTrack | null; screenAudioTrack: MediaStreamTrack | null;
lkVideoTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream)
lkScreenTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream)
} }
export interface UserTile { export interface UserTile {
@@ -54,6 +56,7 @@ export interface UserTile {
participant: ParticipantInfo; participant: ParticipantInfo;
videoTrack: MediaStreamTrack | null; // camera only videoTrack: MediaStreamTrack | null; // camera only
audioTrack: MediaStreamTrack | null; // mic audioTrack: MediaStreamTrack | null; // mic
lkVideoTrack: Track | null; // LiveKit Track for attach/detach
} }
export interface StreamTile { export interface StreamTile {
@@ -62,6 +65,7 @@ export interface StreamTile {
participant: ParticipantInfo; participant: ParticipantInfo;
screenTrack: MediaStreamTrack | null; screenTrack: MediaStreamTrack | null;
screenAudioTrack: MediaStreamTrack | null; screenAudioTrack: MediaStreamTrack | null;
lkScreenTrack: Track | null; // LiveKit Track for attach/detach
} }
export type GridTile = UserTile | StreamTile; export type GridTile = UserTile | StreamTile;
@@ -69,12 +73,14 @@ export type GridTile = UserTile | StreamTile;
export function deriveGridTiles(participants: ParticipantInfo[]): GridTile[] { export function deriveGridTiles(participants: ParticipantInfo[]): GridTile[] {
const tiles: GridTile[] = []; const tiles: GridTile[] = [];
for (const p of participants) { for (const p of participants) {
const hasLiveVideo = p.isCameraOn && p.videoTrack?.readyState === 'live';
tiles.push({ tiles.push({
kind: 'user', kind: 'user',
key: p.identity, key: p.identity,
participant: p, participant: p,
videoTrack: (p.isCameraOn && p.videoTrack?.readyState === 'live') ? p.videoTrack : null, videoTrack: hasLiveVideo ? p.videoTrack : null,
audioTrack: p.audioTrack, audioTrack: p.audioTrack,
lkVideoTrack: hasLiveVideo ? p.lkVideoTrack : null,
}); });
if (p.isScreenSharing) { if (p.isScreenSharing) {
tiles.push({ tiles.push({
@@ -83,6 +89,7 @@ export function deriveGridTiles(participants: ParticipantInfo[]): GridTile[] {
participant: p, participant: p,
screenTrack: p.screenTrack, screenTrack: p.screenTrack,
screenAudioTrack: p.screenAudioTrack, screenAudioTrack: p.screenAudioTrack,
lkScreenTrack: p.lkScreenTrack,
}); });
} }
} }
@@ -149,6 +156,8 @@ export function useLiveKit() {
let videoTrack: MediaStreamTrack | null = null; let videoTrack: MediaStreamTrack | null = null;
let screenTrack: MediaStreamTrack | null = null; let screenTrack: MediaStreamTrack | null = null;
let screenAudioTrack: MediaStreamTrack | null = null; let screenAudioTrack: MediaStreamTrack | null = null;
let lkVideoTrack: Track | null = null;
let lkScreenTrack: Track | null = null;
let hasScreenSharePublication = false; let hasScreenSharePublication = false;
p.trackPublications.forEach((pub) => { p.trackPublications.forEach((pub) => {
// Detect screen share publication even if unsubscribed // Detect screen share publication even if unsubscribed
@@ -164,8 +173,8 @@ export function useLiveKit() {
if (!mt || mt.readyState !== 'live') return; if (!mt || mt.readyState !== 'live') return;
if (pub.source === Track.Source.Microphone) audioTrack = mt; if (pub.source === Track.Source.Microphone) audioTrack = mt;
else if (pub.source === Track.Source.Camera && p.isCameraEnabled) videoTrack = mt; else if (pub.source === Track.Source.Camera && p.isCameraEnabled) { videoTrack = mt; lkVideoTrack = track; }
else if (pub.source === Track.Source.ScreenShare) screenTrack = mt; else if (pub.source === Track.Source.ScreenShare) { screenTrack = mt; lkScreenTrack = track; }
else if (pub.source === Track.Source.ScreenShareAudio) screenAudioTrack = mt; else if (pub.source === Track.Source.ScreenShareAudio) screenAudioTrack = mt;
}); });
@@ -194,6 +203,8 @@ export function useLiveKit() {
videoTrack, videoTrack,
screenTrack, screenTrack,
screenAudioTrack, screenAudioTrack,
lkVideoTrack,
lkScreenTrack,
}); });
}; };
processParticipant(r.localParticipant, true); processParticipant(r.localParticipant, true);
+14 -9
View File
@@ -18,7 +18,7 @@ export interface OverdriveOptions {
export interface ScreenShareBuildResult { export interface ScreenShareBuildResult {
capture: { width: number; height: number; frameRate: number }; capture: { width: number; height: number; frameRate: number };
publish: { videoCodec: 'h264'; videoEncoding: { maxBitrate: number; maxFramerate: number }; simulcast: false }; publish: { videoCodec: 'vp9'; videoEncoding: { maxBitrate: number; maxFramerate: number }; simulcast: false };
overdrive: OverdriveOptions; overdrive: OverdriveOptions;
contentHint: 'motion' | 'detail'; contentHint: 'motion' | 'detail';
} }
@@ -63,7 +63,7 @@ export function buildScreenShareOptions(config: ScreenShareConfig): ScreenShareB
return { return {
capture: { width, height, frameRate: fps }, capture: { width, height, frameRate: fps },
publish: { publish: {
videoCodec: 'h264', videoCodec: 'vp9',
videoEncoding: { maxBitrate, maxFramerate: fps }, videoEncoding: { maxBitrate, maxFramerate: fps },
simulcast: false, simulcast: false,
}, },
@@ -99,14 +99,19 @@ export async function applyOverdrive(
if (!sender) return; if (!sender) return;
const params = sender.getParameters(); const params = sender.getParameters();
if (!params.encodings?.[0]) return; if (!params.encodings?.length) return;
params.encodings[0].maxBitrate = options.maxBitrate; // Target the highest-quality layer. With simulcast, encodings[0] is the
params.encodings[0].maxFramerate = options.maxFramerate; // lowest layer; our overdrive must hit the top layer so the custom bitrate
params.encodings[0].networkPriority = 'high'; // slider controls the full-resolution stream, not the quarter-res one.
// For non-simulcast tracks (single encoding), length - 1 === 0.
const idx = params.encodings.length - 1;
params.encodings[idx]!.maxBitrate = options.maxBitrate;
params.encodings[idx]!.maxFramerate = options.maxFramerate;
params.encodings[idx]!.networkPriority = 'high';
(params as any).degradationPreference = options.degradationPreference; (params as any).degradationPreference = options.degradationPreference;
if (options.minBitrate > 0) { if (options.minBitrate > 0) {
(params.encodings[0] as any).minBitrate = options.minBitrate; (params.encodings[idx] as any).minBitrate = options.minBitrate;
} }
await sender.setParameters(params); await sender.setParameters(params);
@@ -134,9 +139,9 @@ export async function startScreenShare(room: Room): Promise<boolean> {
// @ts-ignore — LiveKit accepts frameRate at capture level // @ts-ignore — LiveKit accepts frameRate at capture level
frameRate: opts.capture.frameRate, frameRate: opts.capture.frameRate,
}, { }, {
videoCodec: 'h264', videoCodec: opts.publish.videoCodec,
videoEncoding: opts.publish.videoEncoding, videoEncoding: opts.publish.videoEncoding,
simulcast: false, simulcast: opts.publish.simulcast,
} as any); } as any);
if (!track) { if (!track) {