mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-10 05:57:07 +00:00
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
/*
|
|
Copyright 2026 Element Creations Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { combineLatest, map, type Observable, of, switchMap } from "rxjs";
|
|
import {
|
|
type LocalParticipant,
|
|
type RemoteParticipant,
|
|
Track,
|
|
} from "livekit-client";
|
|
|
|
import { type ObservableScope } from "../state/ObservableScope.ts";
|
|
import { type Behavior } from "../state/Behavior.ts";
|
|
import {
|
|
observeInboundRtpStreamStats$,
|
|
observeOutboundRtpStreamStats$,
|
|
} from "../state/media/observeRtpStreamStats";
|
|
|
|
type Size = {
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export function videoFit$(
|
|
scope: ObservableScope,
|
|
videoSize$: Observable<Size | undefined>,
|
|
tileSize$: Observable<Size | undefined>,
|
|
): Behavior<"cover" | "contain"> {
|
|
const fit$ = combineLatest([videoSize$, tileSize$]).pipe(
|
|
map(([videoSize, tileSize]) => {
|
|
if (!videoSize || !tileSize) {
|
|
// If we don't have the sizes, default to cover to avoid black bars.
|
|
// This is a reasonable default as it will ensure the video fills the tile, even if it means cropping.
|
|
return "cover";
|
|
}
|
|
if (
|
|
videoSize.width === 0 ||
|
|
videoSize.height === 0 ||
|
|
tileSize.width === 0 ||
|
|
tileSize.height === 0
|
|
) {
|
|
// If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars.
|
|
return "cover";
|
|
}
|
|
const videoAspectRatio = videoSize.width / videoSize.height;
|
|
const tileAspectRatio = tileSize.width / tileSize.height;
|
|
|
|
// If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa,
|
|
// we want to use "contain" (fit) mode to avoid excessive cropping
|
|
const videoIsLandscape = videoAspectRatio > 1;
|
|
const tileIsLandscape = tileAspectRatio > 1;
|
|
|
|
// If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.)
|
|
// If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.)
|
|
return videoIsLandscape === tileIsLandscape ? "cover" : "contain";
|
|
}),
|
|
);
|
|
|
|
return scope.behavior(fit$, "cover");
|
|
}
|
|
|
|
/**
|
|
* Helper function to get the video size from a participant.
|
|
* It observes the participant's video track stats and extracts the frame width and height.
|
|
* @param participant$ - an Observable of a LocalParticipant or RemoteParticipant, or null if no participant is selected.
|
|
* @returns an Observable of the video size (width and height) or undefined if the size cannot be determined.
|
|
*/
|
|
export function videoSizeFromParticipant$(
|
|
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
|
): Observable<{ width: number; height: number } | undefined> {
|
|
return participant$
|
|
.pipe(
|
|
// If we have a participant, observe their video track stats. If not, return undefined.
|
|
switchMap((p) => {
|
|
if (!p) return of(undefined);
|
|
if (p.isLocal) {
|
|
return observeOutboundRtpStreamStats$(p, Track.Source.Camera);
|
|
} else {
|
|
return observeInboundRtpStreamStats$(p, Track.Source.Camera);
|
|
}
|
|
}),
|
|
)
|
|
.pipe(
|
|
// Extract the frame width and height from the stats. If we don't have valid stats, return undefined.
|
|
map((stats) => {
|
|
if (!stats) return undefined;
|
|
if (
|
|
// For video tracks, frameWidth and frameHeight should be numbers. If they're not, we can't determine the size.
|
|
typeof stats.frameWidth !== "number" ||
|
|
typeof stats.frameHeight !== "number"
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
width: stats.frameWidth,
|
|
height: stats.frameHeight,
|
|
};
|
|
}),
|
|
);
|
|
}
|