Files
Adam Shiervani bfa7336bea fix(video): disable H.265 on Linux to avoid undecodable streams (#1435)
Many Linux browsers (Chrome on NVIDIA proprietary, Brave/Chromium on
Wayland, most Firefox builds) advertise H.265 in
RTCRtpReceiver.getCapabilities and in the SDP offer but cannot actually
decode the stream, leaving users stuck on a black "Loading video
stream..." screen (#1413).

On Linux desktop we now hide H.265 from the codec dropdown and strip it
from the video transceiver's setCodecPreferences immediately before
createOffer, so the existing server-side resolveCodec naturally
negotiates H.264. No protocol, backend, or persisted-preference changes.
2026-05-01 11:14:20 +02:00

348 lines
14 KiB
TypeScript

import semver from "semver";
import { KeySequence } from "@hooks/stores";
import { getLocale, locales, type LocalizedString } from "@localizations/runtime.js";
import { m } from "@localizations/messages.js";
import { CLOUD_BACKWARDS_COMPATIBLE_VERSION, CLOUD_ENABLE_VERSIONED_UI } from "@/ui.config";
const isInvalidDate = (date: Date) => date instanceof Date && isNaN(date.getTime());
export const formatters = {
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
isInvalidDate(date)
? "Invalid Date"
: new Intl.DateTimeFormat(getLocale() || "en-US", {
year: "numeric",
month: "long",
day: "numeric",
...(options || {}),
}).format(date),
bytes: (bytes: number, decimals = 2) => {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
},
hertz: (hz: number, decimals = 2) => {
if (!+hz) return "0 Hz";
const k = 1000; // The scaling factor for Hertz is 1000
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Hz", "kHz", "MHz", "GHz", "THz", "PHz", "EHz", "ZHz", "YHz"];
const i = Math.floor(Math.log(hz) / Math.log(k));
return `${parseFloat((hz / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
},
timeAgo: (date: Date, options?: Intl.RelativeTimeFormatOptions) => {
const relativeTimeFormat = new Intl.RelativeTimeFormat(getLocale() || "en-US", {
numeric: "auto",
...(options || {}),
});
// Note, do not translate the unit names in DIVISIONS, as they must match Intl.RelativeTimeFormatUnit
const DIVISIONS: {
amount: number;
name: Intl.RelativeTimeFormatUnit;
}[] = [
{ amount: 60, name: "seconds" },
{ amount: 60, name: "minutes" },
{ amount: 24, name: "hours" },
{ amount: 7, name: "days" },
{ amount: 4.34524, name: "weeks" },
{ amount: 12, name: "months" },
{ amount: Number.POSITIVE_INFINITY, name: "years" },
];
let duration = (date.valueOf() - new Date().valueOf()) / 1000;
for (const division of DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return relativeTimeFormat.format(Math.round(duration), division.name);
}
duration /= division.amount;
}
},
price: (price: number | bigint | string, options?: Intl.NumberFormatOptions) => {
const opts: Intl.NumberFormatOptions = {
style: "currency",
currency: "USD",
...(options || {}),
};
// Convert the price to a number for comparison
const numericPrice = typeof price === "string" ? parseFloat(price) : Number(price);
// Check if the price is less than 1 and not zero, then adjust minimumFractionDigits
if (numericPrice > 0 && numericPrice < 1) {
opts.minimumFractionDigits = Math.max(2, -Math.floor(Math.log10(numericPrice)));
} else {
opts.minimumFractionDigits = 0;
}
return new Intl.NumberFormat(getLocale() || "en-US", opts).format(numericPrice);
},
truncateMiddle: (str: string | null | undefined, maxLength: number): string => {
if (!str) return "";
if (str.length <= maxLength) {
return str;
}
const halfLength = Math.floor(maxLength / 2);
const firstPart = str.slice(0, halfLength - 1);
const lastPart = str.slice(-halfLength + 2);
return `${firstPart}...${lastPart}`;
},
};
export function someIterable<T>(iterable: Iterable<T>, predicate: (item: T) => boolean): boolean {
for (const item of iterable) {
if (predicate(item)) return true;
}
return false;
}
export const VIDEO = new Blob(
[
new Uint8Array([
0, 0, 0, 28, 102, 116, 121, 112, 105, 115, 111, 109, 0, 0, 2, 0, 105, 115, 111, 109, 105, 115,
111, 50, 109, 112, 52, 49, 0, 0, 0, 8, 102, 114, 101, 101, 0, 0, 2, 239, 109, 100, 97, 116,
33, 16, 5, 32, 164, 27, 255, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
55, 167, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 112, 33, 16, 5, 32, 164, 27, 255, 192, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 55, 167, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 112, 0, 0, 2, 194, 109, 111, 111, 118, 0, 0, 0, 108, 109, 118, 104, 100,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 232, 0, 0, 0, 47, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 1, 236, 116, 114, 97, 107, 0, 0, 0, 92, 116, 107, 104, 100, 0,
0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 47, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 101, 100, 116,
115, 0, 0, 0, 28, 101, 108, 115, 116, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 47, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 1, 100, 109, 100, 105, 97, 0, 0, 0, 32, 109, 100, 104, 100, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 172, 68, 0, 0, 8, 0, 85, 196, 0, 0, 0, 0, 0, 45, 104, 100, 108, 114, 0,
0, 0, 0, 0, 0, 0, 0, 115, 111, 117, 110, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 83, 111, 117,
110, 100, 72, 97, 110, 100, 108, 101, 114, 0, 0, 0, 1, 15, 109, 105, 110, 102, 0, 0, 0, 16,
115, 109, 104, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 100, 105, 110, 102, 0, 0, 0, 28, 100,
114, 101, 102, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 12, 117, 114, 108, 32, 0, 0, 0, 1, 0, 0, 0,
211, 115, 116, 98, 108, 0, 0, 0, 103, 115, 116, 115, 100, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 87,
109, 112, 52, 97, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 16, 0, 0, 0, 0,
172, 68, 0, 0, 0, 0, 0, 51, 101, 115, 100, 115, 0, 0, 0, 0, 3, 128, 128, 128, 34, 0, 2, 0, 4,
128, 128, 128, 20, 64, 21, 0, 0, 0, 0, 1, 244, 0, 0, 1, 243, 249, 5, 128, 128, 128, 2, 18, 16,
6, 128, 128, 128, 1, 2, 0, 0, 0, 24, 115, 116, 116, 115, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 2,
0, 0, 4, 0, 0, 0, 0, 28, 115, 116, 115, 99, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 2, 0,
0, 0, 1, 0, 0, 0, 28, 115, 116, 115, 122, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 1, 115, 0,
0, 1, 116, 0, 0, 0, 20, 115, 116, 99, 111, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 44, 0, 0, 0, 98,
117, 100, 116, 97, 0, 0, 0, 90, 109, 101, 116, 97, 0, 0, 0, 0, 0, 0, 0, 33, 104, 100, 108,
114, 0, 0, 0, 0, 0, 0, 0, 0, 109, 100, 105, 114, 97, 112, 112, 108, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 45, 105, 108, 115, 116, 0, 0, 0, 37, 169, 116, 111, 111, 0, 0, 0, 29, 100, 97, 116,
97, 0, 0, 0, 1, 0, 0, 0, 0, 76, 97, 118, 102, 53, 54, 46, 52, 48, 46, 49, 48, 49,
]),
],
{ type: "video/mp4" },
);
export function canAutoPlayVideo(
muted = true,
timeout = 250,
inline = false,
): Promise<{ result: boolean; error: Error | null }> {
const videoElm = document.createElement("video");
videoElm.muted = muted;
if (muted) {
videoElm.setAttribute("muted", "muted");
}
if (inline) {
videoElm.setAttribute("playsinline", "playsinline");
}
videoElm.src = URL.createObjectURL(VIDEO);
return new Promise(resolve => {
const playResult = videoElm.play();
const timeoutId = setTimeout(() => {
sendOutput(false, new Error(`Timeout ${timeout} ms has been reached`));
}, timeout);
const sendOutput = (result: boolean, error: Error | null = null) => {
videoElm.remove();
videoElm.src = "";
clearTimeout(timeoutId);
resolve({ result, error });
};
if (playResult !== undefined) {
playResult
.then(() => {
sendOutput(true);
})
.catch(playError => {
sendOutput(false, playError);
});
} else {
sendOutput(true);
}
});
}
export function isMac() {
return !!/mac/i.exec(navigator.platform);
}
export function isWindows() {
return !!/win/i.exec(navigator.platform);
}
export function isIOS() {
return (
!!/ipad/i.exec(navigator.platform) ||
!!/iphone/i.exec(navigator.platform) ||
!!/ipod/i.exec(navigator.platform)
);
}
export function isAndroid() {
/* Android sets navigator.platform to Linux :/ */
return !!navigator.userAgent.match("Android ");
}
export function isChromeOS() {
/* ChromeOS sets navigator.platform to Linux :/ */
return !!navigator.userAgent.match(" CrOS ");
}
/**
* Linux desktop browsers, excluding Android and ChromeOS.
*
* Used to gate H.265 support: many Linux browsers advertise H.265 in
* `RTCRtpReceiver.getCapabilities()` and in their SDP offers but cannot
* actually decode the stream (Chrome on NVIDIA proprietary, Brave/Chromium
* on Wayland, Firefox on most Linux setups), leaving users stuck on the
* "Loading video stream..." screen. See jetkvm/kvm#1413.
*/
export function isLinuxDesktop() {
const uaData = (navigator as Navigator & {
userAgentData?: { platform?: string };
}).userAgentData;
if (uaData?.platform) return uaData.platform === "Linux";
return /\bLinux\b/.test(navigator.userAgent) && !isAndroid() && !isChromeOS();
}
export function normalizeSortOrders(macros: KeySequence[]): KeySequence[] {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
}
// Retrieves a localized message by its key, with optional inputs and options.
function getLocalizedMessage(
messageKey: string, // name of the message in a localization file (e.g. "some_action_error")
inputs?: Record<string, unknown>, // replaces variables in the message (e.g. {error: err.message})
options?: Record<string, unknown>, // used to override the locale (e.g. {locale: "de"})
): LocalizedString | undefined {
try {
type MessageFn = (
inputs?: Record<string, unknown>,
options?: Record<string, unknown>,
) => LocalizedString;
const fn = (m as unknown as Record<string, MessageFn | undefined>)[messageKey];
return fn ? fn(inputs, options) : undefined;
} catch {
return undefined;
}
}
type LocaleCode = (typeof locales)[number];
export function map_locale_code_to_name(
currentLocale: LocaleCode,
locale: string,
): [string, string] {
if (locale === "") {
return [m.locale_auto(), ""];
}
// Use the locale directly with the message function
// The key pattern is locale_{locale} where underscores replace hyphens
const messageKey = `locale_${locale.replace("-", "_")}` as const;
const localizedName = getLocalizedMessage(messageKey, undefined, { locale: currentLocale });
const nativeName = getLocalizedMessage(messageKey, undefined, { locale });
if (localizedName && nativeName) {
return [localizedName, nativeName];
}
// Fallback if locale is not found or function not available
return [locale, ""];
}
export function deleteCookie(name: string, domain?: string, path = "/") {
const domainPart = domain ? `; domain=${domain}` : "";
// max-age=0 removes the cookie immediately in modern browsers
document.cookie = `${name}=; path=${path}; max-age=0${domainPart}`;
// fallback: set an expires in the past for older agents
document.cookie = `${name}=; path=${path}; expires=Thu, 01 Jan 1970 00:00:00 GMT${domainPart}`;
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Builds a versioned cloud URL for a device.
* Uses the device's app version to construct /v/{version}/devices/{id}{path}
* Falls back to CLOUD_BACKWARDS_COMPATIBLE_VERSION for older or invalid versions.
*/
export function buildCloudUrl(deviceId: string, appVersion: string | undefined, path = ""): string {
let uri = `/devices/${deviceId}${path}`;
if (CLOUD_ENABLE_VERSIONED_UI) {
const version =
appVersion &&
semver.valid(appVersion) &&
semver.gte(appVersion, CLOUD_BACKWARDS_COMPATIBLE_VERSION)
? appVersion
: CLOUD_BACKWARDS_COMPATIBLE_VERSION;
uri = `/v/${version}${uri}`;
}
return new URL(uri, window.location.origin).href;
}