feat(client): screen share FPS setting with 60 and 120 fps options

Screen share frame rate was hardcoded per quality (5/15/30). Add a
"Screen Share FPS" setting (30 default / 60 / 120) next to Stream Quality:

- 30 keeps the existing per-quality caps unchanged
- 60/120 override the capture constraints and publish maxFramerate for all
  qualities, with bitrate scaled 1.5x/2x to keep the image sharp
- "source" quality (no fixed resolution) applies the fps to the live
  capture track via applyConstraints, best-effort

Actual delivered fps still depends on what the capture source and display
can sustain.

Closes #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 10:50:11 +00:00
parent 85f05e999c
commit 6f0a04113f
5 changed files with 242 additions and 11 deletions
@@ -269,6 +269,43 @@ function buildVoiceAudioTabInner(
section.appendChild(qualityDesc);
section.appendChild(qualitySelect);
// Screen share FPS selector
const fpsHeader = createElement("h3", {}, "Screen Share FPS");
const fpsDesc = createElement(
"p",
{
style: "color:var(--text-muted);font-size:12px;margin:0 0 8px",
},
"Higher frame rates use more bandwidth and depend on what the capture source and display can deliver. Takes effect the next time you start sharing.",
);
const fpsSelect = createElement("select", {
class: "form-input",
style: "width:100%;margin-bottom:16px",
});
const fpsOptions: Array<[number, string]> = [
[30, "30 FPS (default)"],
[60, "60 FPS"],
[120, "120 FPS"],
];
const savedFpsRaw = loadPref<number>("screenShareFps", 30);
const savedFps = savedFpsRaw === 60 || savedFpsRaw === 120 ? savedFpsRaw : 30;
for (const [value, label] of fpsOptions) {
const opt = createElement("option", { value: String(value) }, label);
if (value === savedFps) opt.setAttribute("selected", "");
fpsSelect.appendChild(opt);
}
fpsSelect.value = String(savedFps);
fpsSelect.addEventListener(
"change",
() => {
savePref("screenShareFps", Number(fpsSelect.value));
},
{ signal },
);
section.appendChild(fpsHeader);
section.appendChild(fpsDesc);
section.appendChild(fpsSelect);
// Video device selector
const videoHeader = createElement("h3", {}, "Video Device");
const videoSelect = createElement("select", {
@@ -32,8 +32,10 @@ import {
type ScreenTrackState,
CAMERA_PRESETS,
CAMERA_PUBLISH_BITRATES,
SCREENSHARE_PUBLISH_BITRATES,
getStreamQuality,
getScreenShareFps,
getEffectiveScreenShareFps,
getScreenShareMaxBitrate,
enableCamera as doEnableCamera,
disableCamera as doDisableCamera,
stopManualCameraTrack,
@@ -344,9 +346,11 @@ export class LiveKitSession {
maxBitrate: CAMERA_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 15 : 30,
},
// Fallback for setScreenShareEnabled paths — the manual publish in
// screenShare.ts passes explicit per-track encoding that overrides this.
screenShareEncoding: {
maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30,
maxBitrate: getScreenShareMaxBitrate(quality, getScreenShareFps()),
maxFramerate: getEffectiveScreenShareFps(quality, getScreenShareFps()),
},
},
// End-to-end encryption: SFrame-based E2EE using a server-distributed
+59 -4
View File
@@ -72,6 +72,50 @@ export function getStreamQuality(): StreamQuality {
return "high";
}
// ---------------------------------------------------------------------------
// Screen share frame rate
// ---------------------------------------------------------------------------
/** Saved screen share FPS preference. 30 is the default and preserves the
* historical per-quality caps (5/15/30); 60/120 are explicit overrides. */
export function getScreenShareFps(): number {
const saved = loadPref<number>("screenShareFps", 30);
return saved === 60 || saved === 120 ? saved : 30;
}
/** Effective capture/publish frame rate for a quality + fps preference. */
export function getEffectiveScreenShareFps(quality: StreamQuality, fps: number): number {
if (fps !== 60 && fps !== 120) {
return quality === "low" ? 5 : quality === "medium" ? 15 : 30;
}
return fps;
}
/** High frame rates need proportionally more bitrate to stay sharp. */
const FPS_BITRATE_MULTIPLIER: Readonly<Record<number, number>> = { 60: 1.5, 120: 2 };
export function getScreenShareMaxBitrate(quality: StreamQuality, fps: number): number {
const multiplier = FPS_BITRATE_MULTIPLIER[fps] ?? 1;
return Math.round(SCREENSHARE_PUBLISH_BITRATES[quality] * multiplier);
}
/** 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. */
export function getScreenShareCaptureOptions(
quality: StreamQuality,
fps: number,
): ScreenShareCaptureOptions {
const preset = SCREENSHARE_PRESETS[quality];
if (preset.resolution === undefined) return preset;
return {
...preset,
resolution: { ...preset.resolution, frameRate: getEffectiveScreenShareFps(quality, fps) },
};
}
// ---------------------------------------------------------------------------
// Dependencies injected by the caller (LiveKitSession)
// ---------------------------------------------------------------------------
@@ -203,9 +247,12 @@ export async function enableScreenshare(
}
setLocalScreenshare(true);
const quality = getStreamQuality();
const fps = getScreenShareFps();
const effectiveFps = getEffectiveScreenShareFps(quality, fps);
const maxBitrate = getScreenShareMaxBitrate(quality, fps);
try {
stopManualScreenTracks(state, room);
const screenTracks = await createLocalScreenTracks(SCREENSHARE_PRESETS[quality]);
const screenTracks = await createLocalScreenTracks(getScreenShareCaptureOptions(quality, fps));
state.manualScreenTracks = screenTracks;
for (const track of screenTracks) {
const isVideo = track.kind === Track.Kind.Video;
@@ -216,8 +263,8 @@ export async function enableScreenshare(
...(isVideo
? {
videoEncoding: {
maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality],
maxFramerate: quality === "low" ? 5 : quality === "medium" ? 15 : 30,
maxBitrate,
maxFramerate: effectiveFps,
},
}
: {}),
@@ -226,6 +273,14 @@ 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",
() => {
@@ -237,7 +292,7 @@ export async function enableScreenshare(
}
ws.send({ type: "voice_screenshare", payload: { enabled: true } });
deps.reapplyAudioPipeline();
log.info("Screenshare enabled", { quality, maxBitrate: SCREENSHARE_PUBLISH_BITRATES[quality] });
log.info("Screenshare enabled", { quality, fps: effectiveFps, maxBitrate });
} catch (err) {
// BUG-100: Stop created tracks to release screen capture if publish failed.
for (const t of state.manualScreenTracks) {
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { mockLoadPref } = vi.hoisted(() => ({
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
savePref: vi.fn(),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@stores/voice.store", () => ({
setLocalCamera: vi.fn(),
setLocalScreenshare: vi.fn(),
}));
import {
getScreenShareFps,
getEffectiveScreenShareFps,
getScreenShareMaxBitrate,
getScreenShareCaptureOptions,
SCREENSHARE_PRESETS,
SCREENSHARE_PUBLISH_BITRATES,
type StreamQuality,
} from "@lib/screenShare";
describe("screen share FPS", () => {
beforeEach(() => {
mockLoadPref.mockReset();
mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal);
});
describe("getScreenShareFps", () => {
it("defaults to 30", () => {
expect(getScreenShareFps()).toBe(30);
});
it("accepts 60 and 120", () => {
mockLoadPref.mockReturnValue(60);
expect(getScreenShareFps()).toBe(60);
mockLoadPref.mockReturnValue(120);
expect(getScreenShareFps()).toBe(120);
});
it("falls back to 30 on garbage values", () => {
for (const garbage of [45, 0, -1, "60", null, undefined, NaN]) {
mockLoadPref.mockReturnValue(garbage);
expect(getScreenShareFps()).toBe(30);
}
});
});
describe("getEffectiveScreenShareFps", () => {
it("keeps historical per-quality caps at the default 30", () => {
expect(getEffectiveScreenShareFps("low", 30)).toBe(5);
expect(getEffectiveScreenShareFps("medium", 30)).toBe(15);
expect(getEffectiveScreenShareFps("high", 30)).toBe(30);
expect(getEffectiveScreenShareFps("source", 30)).toBe(30);
});
it("applies explicit 60/120 overrides to every quality", () => {
const qualities: StreamQuality[] = ["low", "medium", "high", "source"];
for (const q of qualities) {
expect(getEffectiveScreenShareFps(q, 60)).toBe(60);
expect(getEffectiveScreenShareFps(q, 120)).toBe(120);
}
});
});
describe("getScreenShareMaxBitrate", () => {
it("returns the base bitrate at 30 fps", () => {
expect(getScreenShareMaxBitrate("high", 30)).toBe(SCREENSHARE_PUBLISH_BITRATES.high);
});
it("scales bitrate up for 60 and 120 fps", () => {
expect(getScreenShareMaxBitrate("high", 60)).toBe(SCREENSHARE_PUBLISH_BITRATES.high * 1.5);
expect(getScreenShareMaxBitrate("source", 120)).toBe(
SCREENSHARE_PUBLISH_BITRATES.source * 2,
);
});
});
describe("getScreenShareCaptureOptions", () => {
it("injects frameRate into presets that have a resolution", () => {
const opts = getScreenShareCaptureOptions("high", 60);
expect(opts.resolution?.frameRate).toBe(60);
expect(opts.resolution?.width).toBe(SCREENSHARE_PRESETS.high.resolution?.width);
expect(opts.audio).toBe(true);
});
it("keeps the per-quality fps at the default setting", () => {
expect(getScreenShareCaptureOptions("low", 30).resolution?.frameRate).toBe(5);
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);
expect(opts.resolution).toBeUndefined();
});
it("does not mutate the shared presets", () => {
const before = SCREENSHARE_PRESETS.high.resolution?.frameRate;
getScreenShareCaptureOptions("high", 120);
expect(SCREENSHARE_PRESETS.high.resolution?.frameRate).toBe(before);
});
});
});
@@ -197,8 +197,8 @@ describe("VoiceAudioTab UI structure", () => {
document.body.appendChild(el);
const selects = el.querySelectorAll("select");
// Input, output, stream quality, video = 4 selects
expect(selects.length).toBe(4);
// Input, output, stream quality, screen share fps, video = 5 selects
expect(selects.length).toBe(5);
ac.abort();
});
@@ -357,6 +357,24 @@ describe("VoiceAudioTab UI structure", () => {
ac.abort();
});
it("screen share fps select persists the preference as a number", () => {
stubNavigator();
const ac = new AbortController();
const tab = createVoiceAudioTab(ac.signal);
const el = tab.build();
document.body.appendChild(el);
// Screen share FPS is the 4th select (index 3)
const fpsSelect = el.querySelectorAll("select")[3] as HTMLSelectElement;
expect(fpsSelect.value).toBe("30"); // default
fpsSelect.value = "60";
fpsSelect.dispatchEvent(new Event("change"));
const saved = localStorage.getItem("owncord:settings:screenShareFps");
expect(saved).toBe("60");
ac.abort();
});
it("contains audio processing toggles", () => {
stubNavigator();
const ac = new AbortController();
@@ -488,11 +506,11 @@ describe("VoiceAudioTab UI structure", () => {
// Wait for devices to load
await vi.waitFor(() => {
const videoSelect = el.querySelectorAll("select")[3] as HTMLSelectElement;
const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement;
expect(videoSelect.querySelectorAll("option").length).toBeGreaterThan(1);
});
const videoSelect = el.querySelectorAll("select")[3] as HTMLSelectElement;
const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement;
videoSelect.value = "cam-1";
videoSelect.dispatchEvent(new Event("change"));