Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a01e0087 | |||
| 9fb1f1f7ee | |||
| e2254819c7 | |||
| e71bf0623f | |||
| b12d32dfba | |||
| 359f9fd1f5 |
@@ -133,7 +133,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.37
|
||||
VIDCONF_VERSION=0.0.39
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -3,6 +3,39 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [0.0.39] — 2026-08-11
|
||||
|
||||
### Добавлено
|
||||
- Вкладка «Зайти как гость» на экранах входа и регистрации — ведёт на
|
||||
`/join`, чтобы незалогиненный пользователь мог подключиться по номеру
|
||||
конференции, не выбирая между входом и регистрацией. На узких экранах
|
||||
(375/360px) подпись сокращается до «Гость» (та же схема коротких
|
||||
подписей, что у «Мини-окно»/«Мини» в тулбаре комнаты).
|
||||
|
||||
### Исправлено
|
||||
- Дата и время в прошлом были запрещены сервером для ЛЮБОЙ конференции
|
||||
при создании, включая закреплённые — хотя закреплённая конференция не
|
||||
разовое событие, а постоянная комната-ссылка, и прошедшая дата для неё
|
||||
осмысленна (например, серия «каждый день с такого-то числа», уже
|
||||
наступившего). Запрет прошлого теперь действует только для обычных
|
||||
конференций.
|
||||
- Поле «Длительность, мин» в форме конференции: числовое состояние
|
||||
(`Number(value) || 0`) не давало очистить поле — стирание тут же
|
||||
превращалось обратно в 0, и следующая цифра визуально дописывалась
|
||||
к нему вместо замены. Состояние переведено на строку, плюс выделение
|
||||
содержимого по фокусу.
|
||||
|
||||
## [0.0.38] — 2026-08-11
|
||||
|
||||
### Исправлено
|
||||
- Safari: при выключении виртуального фона в комнате чернело собственное
|
||||
видео (остальные участники видели картинку нормально). Причина —
|
||||
внутренняя охрана livekit-client от мерцания сравнивала `srcObject` с
|
||||
тем же объектом `MediaStream` и из-за этого не давала сработать
|
||||
собственному обходному пути библиотеки для чёрного кадра в Safari.
|
||||
Теперь после остановки процессора фона элементы принудительно
|
||||
переприцепляются (`detach`/`attach`), пересоздавая `MediaStream`.
|
||||
|
||||
## [0.0.37] — 2026-08-10
|
||||
|
||||
Закрытие технического долга. Без изменений в продуктовой логике, кроме
|
||||
|
||||
@@ -91,7 +91,17 @@ class ConferenceCreateIn(BaseModel):
|
||||
raise ValueError("closed_conference_requires_password")
|
||||
if self.recurrence is not None and not self.is_pinned:
|
||||
raise ValueError("recurrence_requires_pinned")
|
||||
if self.scheduled_at is not None and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE:
|
||||
# Запрет прошлого — только для обычных (не закреплённых) конференций.
|
||||
# Закреплённая конференция — не разовое событие с конкретной датой, а
|
||||
# постоянная комната-ссылка (см. комментарий у `is_pinned` в
|
||||
# `models/conference.py`); `scheduled_at` для неё — не более чем
|
||||
# подсказка в календаре, и прошедшая дата (например, «встречаемся тут
|
||||
# каждый день с такого-то числа», уже наступившего) осмысленна.
|
||||
if (
|
||||
not self.is_pinned
|
||||
and self.scheduled_at is not None
|
||||
and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE
|
||||
):
|
||||
raise ValueError("scheduled_at_in_the_past")
|
||||
return self
|
||||
|
||||
|
||||
@@ -212,6 +212,23 @@ async def test_create_scheduled_in_the_past_returns_422(
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_create_pinned_scheduled_in_the_past_is_allowed(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Закреплённая конференция — не разовое событие, прошлая дата для неё осмысленна."""
|
||||
user = await _make_user(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
past = datetime.now(UTC) - timedelta(hours=1)
|
||||
response = await client.post(
|
||||
"/api/v1/conferences",
|
||||
json={"title": "Pinned past", "scheduled_at": _iso(past), "is_pinned": True},
|
||||
headers=_auth_headers(user),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["is_pinned"] is True
|
||||
|
||||
|
||||
# --- «Мои конференции» ---------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.37}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.39}
|
||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||
|
||||
@@ -75,7 +75,16 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
|
||||
const [title, setTitle] = useState(conference?.title ?? '')
|
||||
const [dateStr, setDateStr] = useState(initialSlot.date)
|
||||
const [timeStr, setTimeStr] = useState(initialSlot.time)
|
||||
const [durationMinutes, setDurationMinutes] = useState(conference?.duration_minutes ?? 30)
|
||||
// Строкой, а не числом: контролируемый number-инпут с числовым состоянием
|
||||
// не даёт очистить поле (`Number('') || 0` тут же возвращает 0, значение
|
||||
// "0" вспыхивает обратно) — строка хранит именно то, что ввёл пользователь,
|
||||
// включая пустоту. Вторая половина жалобы («ввод дописывается после 0», а
|
||||
// не заменяет его) — не про хранение, а про то, что при фокусе курсор
|
||||
// просто вставал рядом с текущим значением; чинится `onFocus={select()}`
|
||||
// у самого `<input>` ниже. `durationMinutes` — производное число для
|
||||
// валидации и отправки.
|
||||
const [durationMinutesInput, setDurationMinutesInput] = useState(String(conference?.duration_minutes ?? 30))
|
||||
const durationMinutes = Number(durationMinutesInput) || 0
|
||||
|
||||
const [isClosed, setIsClosed] = useState(conference?.is_closed ?? false)
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -306,8 +315,9 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
|
||||
type="number"
|
||||
min={5}
|
||||
step={5}
|
||||
value={durationMinutes}
|
||||
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
|
||||
value={durationMinutesInput}
|
||||
onChange={(e) => setDurationMinutesInput(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -103,6 +103,22 @@ export function useVirtualBackground(
|
||||
if (!track || !desiredUrl) {
|
||||
if (track && processorRef.current) {
|
||||
await track.stopProcessor()
|
||||
// Safari-обход: `stopProcessor` внутри livekit-client меняет трек
|
||||
// в элементе «на месте», переиспользуя тот же объект `MediaStream`
|
||||
// (внутренний `detach` промахивается мимо обработанного трека,
|
||||
// который реально висел в элементе). Из-за этого собственная
|
||||
// защита библиотеки от мерцания (`srcObject !== mediaStream` в
|
||||
// `attachToElement`) не даёт сработать её же обходному пути для
|
||||
// чёрного кадра в Safari. Пересоздаём `MediaStream` вручную —
|
||||
// detach обнулит `srcObject`, attach создаст новый объект, защита
|
||||
// пропустит штатный `play()`. Элемент остаётся в своём документе,
|
||||
// никуда не переезжает. Копия массива обязательна: `detach`
|
||||
// правит `attachedElements` по ходу перебора. Уберётся, если
|
||||
// библиотека когда-нибудь починит эту защиту.
|
||||
for (const el of [...track.attachedElements]) {
|
||||
track.detach(el)
|
||||
track.attach(el)
|
||||
}
|
||||
await processorRef.current.destroy()
|
||||
processorRef.current = null
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ export function LoginPage() {
|
||||
<Link to="/register" className="tab" role="tab" aria-selected="false">
|
||||
Регистрация
|
||||
</Link>
|
||||
<Link to="/join" className="tab" role="tab" aria-selected="false">
|
||||
<span className="label-full">Зайти как гость</span>
|
||||
<span className="label-short">Гость</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form
|
||||
|
||||
@@ -133,6 +133,10 @@ export function RegisterPage() {
|
||||
<span className="tab is-active" role="tab" aria-selected="true">
|
||||
Регистрация
|
||||
</span>
|
||||
<Link to="/join" className="tab" role="tab" aria-selected="false">
|
||||
<span className="label-full">Зайти как гость</span>
|
||||
<span className="label-short">Гость</span>
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="screen-title">Создать аккаунт</h2>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
|
||||
@@ -104,6 +104,11 @@
|
||||
}
|
||||
.tab.is-active { background: var(--color-surface); color: var(--color-ink-700); font-weight: 600; box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* Короткая подпись вкладки «Зайти как гость» — на узком экране (см. @media
|
||||
ниже) длинный вариант переносится на 2 строки и делает вкладку выше
|
||||
соседних; та же схема, что у «Мини-окно»/«Мини» в RoomToolbar.tsx/room.css. */
|
||||
.tab .label-short { display: none; }
|
||||
|
||||
.field { margin-bottom: var(--space-5); }
|
||||
.field label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-ink-900); margin-bottom: var(--space-2); }
|
||||
.input-wrap { position: relative; display: flex; align-items: center; }
|
||||
@@ -289,4 +294,13 @@
|
||||
.stat-glass { flex-basis: 100%; }
|
||||
|
||||
.form-panel { padding: var(--space-6) var(--space-4) var(--space-10); }
|
||||
|
||||
/* Три вкладки (Вход/Регистрация/Зайти как гость) на ширину 375/360px в
|
||||
исходный `width: fit-content` не помещаются — растягиваем группу на
|
||||
всю ширину блока и даём каждой вкладке одинаковую долю вместо
|
||||
ужимания по контенту, иначе текст «Регистрация» переносится. */
|
||||
.tabs { width: 100%; }
|
||||
.tab { flex: 1; justify-content: center; padding: 8px 6px; font-size: 13px; }
|
||||
.tab .label-full { display: none; }
|
||||
.tab .label-short { display: inline; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user