diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b4a43b8..8a0546e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.3 — обработка микрофона + +- Шумоподавление и эхоподавление по умолчанию выключены. Ранее сохранённый явный выбор сохраняется. +- В меню настроек микрофона — единый тумблер «Шумоподавление и эхо» с текущим состоянием. +- Основная студия может включать и выключать обработку в карточке кастера без его ручного согласия. Приложение автоматически сообщает результат применения. +- Переключение сохраняет выключенное состояние микрофона, не меняет камеру и не прерывает приватное обращение. +- Сохранена действующая мини-статистика приёма видео, аудио и состояния сети в карточках кастеров. +- Обновляются web и серверные пакеты Windows Server Setup/Linux; отдельная пересборка Windows/macOS Client не нужна для этого изменения web-интерфейса. После обновления сервера перезагрузите CastLink вне эфира. + ## 1.0.2 — серверный выпуск / Server release / 服务器发行版 [Русский](distribution/RELEASE_NOTES_1_0_2.md) · [English](distribution/docs/en/RELEASE_NOTES_1_0_2.md) · [简体中文](distribution/docs/zh-CN/RELEASE_NOTES_1_0_2.md) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d28be5ec..d2aee1e1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1306,7 +1306,6 @@ html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content select { width: 100%; } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--panel-border); } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .guest-noise-control > label { display: flex; align-items: center; gap: 10px; min-height: 44px; font-size: 13px; font-weight: 600; line-height: 1.5; cursor: pointer; } -html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control input[type="checkbox"] { flex: 0 0 18px; width: 18px; height: 18px; min-height: 18px; margin: 0; padding: 0; accent-color: var(--media-accent); } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-noise-control p { margin: 4px 0 0; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .personal-monitor-panel h2 { margin: 0 0 8px; font-size: 14px; } html .guest-room-page.guest-app-shell.graphite-workspace.caster-workspace .guest-settings-content .personal-monitor-panel p { margin: 0 0 12px; font-size: 12px; line-height: 1.6; } diff --git a/apps/web/components/GuestAudioProfileSelector.module.css b/apps/web/components/GuestAudioProfileSelector.module.css new file mode 100644 index 00000000..b09c2370 --- /dev/null +++ b/apps/web/components/GuestAudioProfileSelector.module.css @@ -0,0 +1,14 @@ +.root { min-width: 0; width: 100%; } +.root .control { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 44px; margin: 0; cursor: pointer; } +.copy { display: grid; gap: 4px; min-width: 0; } +.title { color: var(--text); font-size: 13px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; } +.hint { color: var(--muted); font-size: 12px; font-weight: 400; line-height: 1.5; } +.root .switch { appearance: none; flex: none; box-sizing: border-box; width: 44px; height: 26px; min-height: 0; margin: 0; padding: 3px; border: 1px solid var(--field-border); border-radius: 999px; background: var(--secondary-bg); cursor: pointer; transition: background-color 160ms ease-out, border-color 160ms ease-out; } +.switch::before { content: ""; display: block; width: 18px; height: 18px; border-radius: 50%; background: var(--muted); transition: transform 160ms ease-out, background-color 160ms ease-out; } +.root .switch:checked { background: var(--primary); border-color: var(--primary); } +.switch:checked::before { transform: translateX(18px); background: var(--page-bg); } +.root .switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 4px; } +.control:hover .switch:not(:disabled) { border-color: var(--text); } +.control:has(.switch:disabled) { cursor: default; opacity: .6; } +.root .switch:disabled { cursor: default; } +@media (prefers-reduced-motion: reduce) { .root .switch, .switch::before { transition: none; } } diff --git a/apps/web/components/GuestAudioProfileSelector.tsx b/apps/web/components/GuestAudioProfileSelector.tsx index ef1644e5..25e46618 100644 --- a/apps/web/components/GuestAudioProfileSelector.tsx +++ b/apps/web/components/GuestAudioProfileSelector.tsx @@ -1,16 +1,21 @@ "use client"; +import { useId } from "react"; import { useI18n } from "@/components/I18nProvider"; import { guestAudioProfile, type GuestAudioProfile } from "@/lib/guest-audio"; +import styles from "./GuestAudioProfileSelector.module.css"; export function GuestAudioProfileSelector({ value, onChange, disabled = false, busy = false }: { value?: GuestAudioProfile; onChange: (profile: GuestAudioProfile) => void; disabled?: boolean; busy?: boolean }) { - const { t, locale } = useI18n(); - const profile = guestAudioProfile(value); - return
- - {profile === "raw" &&

{t("Без шумоподавления и автоматического усиления. Используйте наушники, чтобы избежать эха.")}

} - {busy && {t("Переключение микрофона…")}} + const { t } = useI18n(); + const id = useId(); + const enabled = guestAudioProfile(value) === "speech"; + return
+ + {busy && {t("Переключение микрофона…")}}
; } diff --git a/apps/web/components/GuestMiniStats.tsx b/apps/web/components/GuestMiniStats.tsx new file mode 100644 index 00000000..a60b979d --- /dev/null +++ b/apps/web/components/GuestMiniStats.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useI18n } from "./I18nProvider"; +import styles from "./GuestVideoCard.module.css"; + +export type GuestMiniStatsData = { + receiveVideoKbps?: number | null; + receiveAudioKbps?: number | null; + networkMeasured?: boolean; + statsSampledAt?: number; + statsSampledAtRef?: { current: number }; + stability: "Хорошая" | "Средняя" | "Плохая"; + packetLoss: number; + jitterMs: number; + rttMs: number; + camera: boolean; + microphone: boolean; +}; + +export function GuestMiniStats({ guest }: { guest: GuestMiniStatsData }) { + const { t, locale } = useI18n(); + const [now, setNow] = useState(Date.now); + useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 2000); return () => clearInterval(timer); }, []); + const sampledAt = guest.statsSampledAtRef?.current ?? guest.statsSampledAt; + const fresh = sampledAt != null && sampledAt > 0 && now - sampledAt < 8000; + const measured = fresh && guest.networkMeasured; + const quality = !measured ? "unknown" : guest.stability === "Хорошая" ? "good" : guest.stability === "Средняя" ? "mid" : "bad"; + const bitrate = (value: number | null | undefined, enabled: boolean) => fresh && enabled && value != null && Number.isFinite(value) && value >= 0 + ? `${value.toLocaleString(locale)} ${t("кбит/с")}` : "—"; + const detail = measured ? t("Потери: {loss} пак. за всё время трека; джиттер: {jitter} мс; RTT: {rtt}", { + loss: guest.packetLoss, jitter: guest.jitterMs, rtt: guest.rttMs > 0 ? `${guest.rttMs} ${t("мс")}` : "—", + }) : t("Нет свежего замера соединения"); + return
+
{t("Приём видео")}
{bitrate(guest.receiveVideoKbps, guest.camera)}
+
{t("Приём аудио")}
{bitrate(guest.receiveAudioKbps, guest.microphone)}
+
{t("Сеть")}