fix(client): make source-quality screen share FPS take effect at capture

createLocalScreenTracks injects a default 1080p30 resolution when none is
set and mutates the passed options object, so (a) a 'source' share was
captured at 30 fps regardless of the FPS setting, with only a best-effort
applyConstraints afterwards, and (b) the shared 'source' preset object was
permanently mutated after the first share. Capture options are now always
copies; 'source' with an explicit 60/120 override passes a zero-size
resolution sentinel (uncapped in livekit's constraint translation) with the
frame rate in the raw video constraints, so the fps applies at
getDisplayMedia time.

Follow-up to #115.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
This commit is contained in:
Claude
2026-07-19 11:20:01 +00:00
parent 1201992ab0
commit d576f06aa1
2 changed files with 35 additions and 16 deletions
+22 -13
View File
@@ -101,18 +101,35 @@ export function getScreenShareMaxBitrate(quality: StreamQuality, fps: number): n
/** Capture options for a quality with the fps preference applied. Presets with
* a fixed resolution get frameRate injected into the getDisplayMedia
* constraints; "source" (no resolution) is handled post-capture via
* applyConstraints because livekit-client only translates the resolution
* object into constraints when width/height are set. */
* constraints. Always returns a copy: createLocalScreenTracks mutates the
* options object in place (it injects a default 1080p30 resolution when none
* is set), which would otherwise corrupt the shared presets.
*
* For "source" (no resolution cap) with an explicit 60/120 override, a
* zero-size resolution suppresses the library's 1080p30 default (the
* constraint translation treats 0 as uncapped) and the frame rate is passed
* through the raw video constraints instead. */
export function getScreenShareCaptureOptions(
quality: StreamQuality,
fps: number,
): ScreenShareCaptureOptions {
const preset = SCREENSHARE_PRESETS[quality];
if (preset.resolution === undefined) return preset;
const effectiveFps = getEffectiveScreenShareFps(quality, fps);
if (preset.resolution === undefined) {
if (fps === 60 || fps === 120) {
return {
...preset,
resolution: { width: 0, height: 0, frameRate: effectiveFps },
// Runtime passes this object verbatim to getDisplayMedia; the declared
// type is narrower than what the library actually accepts.
video: { frameRate: effectiveFps } as ScreenShareCaptureOptions["video"],
};
}
return { ...preset };
}
return {
...preset,
resolution: { ...preset.resolution, frameRate: getEffectiveScreenShareFps(quality, fps) },
resolution: { ...preset.resolution, frameRate: effectiveFps },
};
}
@@ -273,14 +290,6 @@ export async function enableScreenshare(
// BUG-101: Listen for OS "Stop sharing" so the app runs the full disable path.
const videoTrack = screenTracks.find((t) => t.kind === Track.Kind.Video);
if (videoTrack) {
// "source" quality has no resolution preset, so the fps preference is
// applied to the live capture track instead. Best-effort: the browser
// delivers whatever the source/display can sustain.
if (quality === "source" && (fps === 60 || fps === 120)) {
videoTrack.mediaStreamTrack.applyConstraints({ frameRate: fps }).catch((err: unknown) => {
log.warn("Screen share FPS constraint rejected", err);
});
}
videoTrack.mediaStreamTrack.addEventListener(
"ended",
() => {
@@ -100,10 +100,20 @@ describe("screen share FPS", () => {
expect(getScreenShareCaptureOptions("medium", 30).resolution?.frameRate).toBe(15);
});
it("returns the source preset unchanged (no resolution to constrain)", () => {
const opts = getScreenShareCaptureOptions("source", 120);
expect(opts).toBe(SCREENSHARE_PRESETS.source);
it("returns a copy of the source preset at the default fps", () => {
const opts = getScreenShareCaptureOptions("source", 30);
expect(opts).not.toBe(SCREENSHARE_PRESETS.source);
expect(opts.resolution).toBeUndefined();
expect(opts.audio).toBe(true);
});
it("uses a zero-size resolution sentinel for source with explicit fps", () => {
const opts = getScreenShareCaptureOptions("source", 120);
expect(opts).not.toBe(SCREENSHARE_PRESETS.source);
// Zero width/height = uncapped in livekit's constraint translation, and
// a defined resolution stops the library injecting its 1080p30 default.
expect(opts.resolution).toEqual({ width: 0, height: 0, frameRate: 120 });
expect(opts.video).toEqual({ frameRate: 120 });
});
it("does not mutate the shared presets", () => {