mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user