Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 1.0.4 server/web · 1.0.1 client — 2026-11-13 — статистика гостей / Guest statistics / 参与者统计

- RU: мини-статистика под карточкой гостя показывает измеренные битрейты приёма видео и микрофона и оценку сети. Маленькая стрелка открывает памятку с текущими числами и порогами; «Нет данных» тоже раскрывается. Обновления сохраняют видео и меню, одинаковые замеры не обновляют весь список. Обновите сервер и затем страницы вне эфира; Client 1.0.1 отображает интерфейс выбранного обновлённого сервера. [Примечания](distribution/RELEASE_NOTES_1_0_4.md).
- EN: guest cards show measured incoming camera/microphone bitrates and the network grade. A small arrow opens the current values and threshold guide, including No data. Updates preserve media and menus; identical samples preserve roster state. Update the server and reload pages off air; Client 1.0.1 displays its selected updated server's UI. [Release notes](distribution/docs/en/RELEASE_NOTES_1_0_4.md).
- 简体中文:参与者卡片显示摄像头和麦克风的实测接收码率及网络等级,小箭头可查看当前值和阈值,无数据时也可展开。更新保留视频和菜单,相同测量不更新整个列表。请在停播时更新服务器并刷新页面;Client 1.0.1 显示所选已更新服务器的界面。[发行说明](distribution/docs/zh-CN/RELEASE_NOTES_1_0_4.md)。

## Web — выравнивание карточек / Card alignment / 卡片对齐

- RU: видео и аудио остаются в двух ровных колонках, сеть — отдельной строкой с оценкой рядом с подписью. Выравнивание сохраняется на узких карточках; опубликованные установочные пакеты 1.0.3 остаются неизменными.
Expand Down
47 changes: 43 additions & 4 deletions apps/web/components/GuestMiniStats.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useI18n } from "./I18nProvider";
import { StudioConsoleIcon } from "./StudioConsoleIcon";
import { positionStudioConsolePopover } from "./studio-console-popover-position";
import styles from "./GuestVideoCard.module.css";

export type GuestMiniStatsData = {
name?: string;
receiveVideoKbps?: number | null;
receiveAudioKbps?: number | null;
networkMeasured?: boolean;
Expand All @@ -21,7 +24,17 @@ export type GuestMiniStatsData = {
export function GuestMiniStats({ guest }: { guest: GuestMiniStatsData }) {
const { t, locale } = useI18n();
const [now, setNow] = useState(Date.now);
const menuId = useId();
const trigger = useRef<HTMLButtonElement>(null), popup = useRef<HTMLDivElement>(null);
const [helpOpen, setHelpOpen] = useState(false);
const positionHelp = () => { if (popup.current && trigger.current) positionStudioConsolePopover(popup.current, trigger.current); };
useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 2000); return () => clearInterval(timer); }, []);
useEffect(() => {
if (!helpOpen) return;
window.addEventListener("resize", positionHelp);
window.addEventListener("scroll", positionHelp, true);
return () => { window.removeEventListener("resize", positionHelp); window.removeEventListener("scroll", positionHelp, true); };
}, [helpOpen]);
const sampledAt = guest.statsSampledAtRef?.current ?? guest.statsSampledAt;
const fresh = sampledAt != null && sampledAt > 0 && now - sampledAt < 8000;
const measured = fresh && guest.networkMeasured;
Expand All @@ -31,9 +44,35 @@ export function GuestMiniStats({ guest }: { guest: GuestMiniStatsData }) {
const detail = measured ? t("Потери: {loss} пак. за всё время трека; джиттер: {jitter} мс; RTT: {rtt}", {
loss: guest.packetLoss, jitter: guest.jitterMs, rtt: guest.rttMs > 0 ? `${guest.rttMs} ${t("мс")}` : "—",
}) : t("Нет свежего замера соединения");
return <dl className={styles.miniStats} aria-label={t("Статистика соединения")}>
return <><dl className={styles.miniStats} aria-label={t("Статистика соединения")}>
<div title={t("Измеренный приём камеры этой студией; без аудио")}><dt>{t("Приём видео")}</dt><dd>{bitrate(guest.receiveVideoKbps, guest.camera)}</dd></div>
<div title={t("Измеренный приём микрофона этой студией; без видео")}><dt>{t("Приём аудио")}</dt><dd>{bitrate(guest.receiveAudioKbps, guest.microphone)}</dd></div>
<div title={detail}><dt>{t("Сеть")}</dt><dd className={styles.network} data-quality={quality}><i aria-hidden="true" />{t(measured ? guest.stability : "Нет данных")}</dd></div>
</dl>;
<div><dt>{t("Сеть")}</dt><dd className={styles.network} data-quality={quality}>
<button ref={trigger} type="button" data-network-help-toggle="true" className={styles.networkHelpToggle} popoverTarget={menuId}
aria-haspopup="dialog" aria-expanded={helpOpen} aria-controls={menuId}
aria-label={guest.name ? t("Памятка о качестве сети: {name}", { name: guest.name }) : t("Памятка о качестве сети")}
onClick={positionHelp}>
<i aria-hidden="true" /><span>{t(measured ? guest.stability : "Нет данных")}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m4 6 4 4 4-4" /></svg>
</button>
</dd></div>
</dl>
<div ref={popup} id={menuId} data-network-help="true" popover="auto" role="dialog" aria-labelledby={`${menuId}-title`} className={styles.networkHelpPopover}
onToggle={event => {
if (event.target !== event.currentTarget) return;
const open = event.newState === "open";
setHelpOpen(open);
if (open) { positionHelp(); popup.current?.querySelector<HTMLButtonElement>("[data-network-help-close]")?.focus({ preventScroll: true }); }
else if (document.activeElement === document.body) trigger.current?.focus({ preventScroll: true });
}}>
<header className={styles.networkHelpHeader}><strong id={`${menuId}-title`}>{t("Качество сети")}</strong><button type="button" data-network-help-close className={styles.networkHelpClose} popoverTarget={menuId} popoverTargetAction="hide" aria-label={t("Закрыть")}><StudioConsoleIcon kind="close" /></button></header>
<p className={styles.networkHelpIntro}>{t("Срабатывает худший из трёх показателей.")}</p>
<ul className={styles.networkHelpGrades}>
<li data-network-help-grade="good"><strong>{t("Хорошая")}</strong><span>{t("Потери ≤ 2 пак. · джиттер ≤ 30 мс · RTT ≤ 150 мс")}</span></li>
<li data-network-help-grade="mid"><strong>{t("Средняя")}</strong><span>{t("Потери 3–10 пак. · джиттер 31–60 мс · RTT 151–300 мс")}</span></li>
<li data-network-help-grade="bad"><strong>{t("Плохая")}</strong><span>{t("Потери > 10 пак. · джиттер > 60 мс · RTT > 300 мс")}</span></li>
</ul>
<p className={styles.networkHelpNote}>{t("Потери — за всё время видеотрека, не процент за последние секунды и не скорость интернета.")}</p>
<div className={styles.networkHelpCurrent}><strong>{t("Текущий замер")}</strong><p>{detail}</p></div>
</div></>;
}
45 changes: 45 additions & 0 deletions apps/web/components/GuestVideoCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,51 @@
:global([data-theme="light"]) .network[data-quality="mid"] { color: #885609; }
:global([data-theme="light"]) .network[data-quality="bad"] { color: #ad2738; }

.network .networkHelpToggle.networkHelpToggle,
:global(html[data-design-preview="on"] .source-browser .source-deck .source-card) .networkHelpToggle.networkHelpToggle {
display: inline-flex; align-items: center; justify-content: flex-end; gap: 5px;
width: auto; min-width: 0; height: 24px; min-height: 24px; margin: calc((1lh - 24px) / 2) 0;
padding: 0; border: 0; border-radius: 3px; background: transparent; box-shadow: none;
color: inherit; font: inherit; line-height: inherit; cursor: pointer;
}
.network .networkHelpToggle.networkHelpToggle:hover { color: inherit; background: transparent; }
.networkHelpToggle svg,
:global(html[data-design-preview="on"] .source-browser .source-deck .source-card) .networkHelpToggle.networkHelpToggle svg { width: 10px; height: 10px; flex: none; transition: transform 120ms ease; }
.networkHelpToggle[aria-expanded="true"] svg { transform: rotate(180deg); }
.networkHelpToggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.networkHelpPopover.networkHelpPopover {
position: fixed; inset: auto; width: min(320px, calc(100vw - 24px)); max-height: calc(100dvh - 24px);
margin: 0; padding: 12px; overflow: auto; overscroll-behavior: contain;
border: 1px solid var(--panel-border); border-radius: 9px;
background: var(--panel-bg); color: var(--text); box-shadow: 0 8px 24px #0003;
font-size: 12px; line-height: 1.45;
}
.networkHelpHeader { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.networkHelpHeader > strong { font-size: 13px; font-weight: 600; }
.networkHelpPopover .networkHelpClose.networkHelpClose,
:global(html[data-design-preview="on"] .source-browser .source-deck .source-card) .networkHelpClose.networkHelpClose {
display: grid; place-items: center; flex: none; width: 24px; min-width: 24px; height: 24px; min-height: 24px;
margin: 0; padding: 4px; border: 0; border-radius: 4px; background: transparent; color: var(--muted);
}
.networkHelpClose svg { width: 16px; height: 16px; }
.networkHelpClose:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.networkHelpIntro.networkHelpIntro { margin: 5px 0 10px; color: var(--muted); font-size: 11px; line-height: 1.45; }
.networkHelpGrades { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }
.networkHelpGrades li { display: grid; gap: 2px; min-width: 0; }
.networkHelpGrades strong { font-size: 11px; font-weight: 600; }
.networkHelpGrades span { font-size: 11px; line-height: 1.45; }
.networkHelpGrades [data-network-help-grade="good"] strong { color: #a3d4b0; }
.networkHelpGrades [data-network-help-grade="mid"] strong { color: #e9bc72; }
.networkHelpGrades [data-network-help-grade="bad"] strong { color: #f3a0a6; }
:global([data-theme="light"]) .networkHelpGrades [data-network-help-grade="good"] strong { color: #245d43; }
:global([data-theme="light"]) .networkHelpGrades [data-network-help-grade="mid"] strong { color: #885609; }
:global([data-theme="light"]) .networkHelpGrades [data-network-help-grade="bad"] strong { color: #ad2738; }
.networkHelpNote.networkHelpNote { margin: 10px 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
.networkHelpCurrent { display: grid; gap: 3px; padding-top: 9px; border-top: 1px solid var(--panel-border); }
.networkHelpCurrent > strong { font-size: 11px; font-weight: 600; }
.networkHelpCurrent > p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
@media (prefers-reduced-motion: reduce) { .networkHelpToggle svg { transition: none; } }

@container (max-width: 165px) {
.miniStats { gap: 4px 0; padding: 4px; line-height: 1.3; }
.miniStats > div { gap: 2px; }
Expand Down
9 changes: 9 additions & 0 deletions apps/web/lib/i18n/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ export const workspacesMessages: Record<string, readonly [string, string]> = {
"Шумоподавление и эхо": ["Noise & echo suppression", "降噪与回声消除"],
"Включено — подавляет фоновый шум и эхо.": ["On — reduces background noise and echo.", "已开启 — 抑制背景噪音和回声。"],
"Выключено — оригинальный звук. Используйте наушники.": ["Off — original sound. Use headphones.", "已关闭 — 原始声音。请使用耳机。"],
"Памятка о качестве сети": ["Network quality guide", "网络质量说明"],
"Памятка о качестве сети: {name}": ["Network quality guide: {name}", "网络质量说明:{name}"],
"Качество сети": ["Network quality", "网络质量"],
"Срабатывает худший из трёх показателей.": ["The worst of the three measurements determines the grade.", "以三项指标中最差的一项确定等级。"],
"Потери ≤ 2 пак. · джиттер ≤ 30 мс · RTT ≤ 150 мс": ["Loss ≤ 2 packets · jitter ≤ 30 ms · RTT ≤ 150 ms", "丢包 ≤ 2 个 · 抖动 ≤ 30 毫秒 · RTT ≤ 150 毫秒"],
"Потери 3–10 пак. · джиттер 31–60 мс · RTT 151–300 мс": ["Loss 3–10 packets · jitter 31–60 ms · RTT 151–300 ms", "丢包 3–10 个 · 抖动 31–60 毫秒 · RTT 151–300 毫秒"],
"Потери > 10 пак. · джиттер > 60 мс · RTT > 300 мс": ["Loss > 10 packets · jitter > 60 ms · RTT > 300 ms", "丢包 > 10 个 · 抖动 > 60 毫秒 · RTT > 300 毫秒"],
"Потери — за всё время видеотрека, не процент за последние секунды и не скорость интернета.": ["Loss is the packet count over the video track lifetime, not a recent percentage or your internet speed.", "丢包是视频轨道的累计数据包数量,并非最近几秒的丢包率,也不代表网速。"],
"Текущий замер": ["Current measurement", "当前测量"],
"Приём видео": ["Video receive", "视频接收"],
"Приём аудио": ["Audio receive", "音频接收"],
"Статистика соединения": ["Connection statistics", "连接统计"],
Expand Down
9 changes: 5 additions & 4 deletions apps/web/tests/guest-mini-stats-refresh.browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const { createRequire } = require('node:module');
const web = path.resolve(__dirname, '..');
const repo = path.resolve(web, '../..');
const req = createRequire(path.join(web, 'package.json'));
const { chromium } = require(process.env.CASTLINK_QA_PLAYWRIGHT || '/opt/codex/runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const { build } = require(process.env.CASTLINK_QA_ESBUILD || '/tmp/castlink-preview-tools/node_modules/esbuild');
const { chromium } = require(process.env.CASTLINK_QA_PLAYWRIGHT || 'playwright');
const { build } = require(process.env.CASTLINK_QA_ESBUILD || 'esbuild');
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'castlink-refresh-proof-'));
let server, browser;
async function main() {
Expand All @@ -32,7 +32,7 @@ async function main() {
response.end('<!doctype html><html lang="ru" data-theme="dark" data-design-preview="on"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/livekit.css"><link rel="stylesheet" href="/globals.css"><link rel="stylesheet" href="/graphite.css"><link rel="stylesheet" href="/new/live-theme.css"><link rel="stylesheet" href="/bundle.css"><div id="root"></div><script src="/bundle.js"></script></html>');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
browser = await chromium.launch({ executablePath: '/usr/bin/chromium', headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage', '--autoplay-policy=no-user-gesture-required'] });
browser = await chromium.launch({ executablePath: process.env.CASTLINK_QA_CHROMIUM_BINARY || '/usr/bin/chromium', headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
let navigations = 0;
Expand Down Expand Up @@ -88,7 +88,8 @@ async function main() {
}
let previous = await inspect('initial');
for (let step = 1; step <= 5; step++) {
await page.clock.runFor(2100);
// A three-tick jump can return the cyclic grade to its starting value.
await page.clock.runFor(1100);
const next = await inspect(`change-${step}`);
assert.ok(next.ticks > previous.ticks, 'fixture actually supplied new data');
assert.ok(next.renders > previous.renders, 'parent React rerender is exercised');
Expand Down
Loading
Loading