Compare commits

...

3 Commits

Author SHA1 Message Date
65c464ab67 feat: updated 2026-05-10 09:50:39 +03:00
1ae330258b chore: add gitignore 2026-05-10 09:49:19 +03:00
50eec199ca chore: add gitignore 2026-05-10 09:48:54 +03:00
20 changed files with 329 additions and 265 deletions

View File

@@ -6,4 +6,8 @@ LIVEKIT_PUBLIC_URL=wss://rtc.linkra.ru
LIVEKIT_API_KEY=linkra_key LIVEKIT_API_KEY=linkra_key
LIVEKIT_API_SECRET=8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87 LIVEKIT_API_SECRET=8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87
CORS_ALLOW_ORIGINS=https://linkra.ru CORS_ALLOW_ORIGINS=https://linkra.ru
TURN_URLS=turn:72.56.247.116:3478?transport=udp,turn:72.56.247.116:3478?transport=tcp
TURN_USERNAME=linkra
TURN_CREDENTIAL=change-me

145
.gitignore vendored Normal file
View File

@@ -0,0 +1,145 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
backend/app/__pycache__/
# C extensions
*.so
*.pyd
*.dll
# Distribution / packaging
.Python
build/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache/
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
# Type checkers / linters
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
.ruff_cache/
# Jupyter Notebook
.ipynb_checkpoints/
# Environments
.env
.env.*
.venv/
venv/
ENV/
env/
env.bak/
venv.bak/
# Poetry
poetry.lock
# Pipenv
Pipfile.lock
# Hatch
.hatch/
# pyenv
.python-version
# Logs
*.log
logs/
# Local databases
*.sqlite3
*.db
# Secrets / credentials
secrets.json
credentials.json
*.pem
*.key
*.crt
# OS generated files
.DS_Store
Thumbs.db
Desktop.ini
# PyCharm / IntelliJ IDEA
.idea/
*.iml
out/
# VS Code (optional)
.vscode/
# Temporary files
*.tmp
*.temp
*.swp
*.swo
*~
# Sphinx docs
docs/_build/
# mkdocs
site/
# celery
celerybeat-schedule
celerybeat.pid
# mypy compiled cache
.mypy_cache/
# pyinstaller
*.manifest
*.spec
# pytest debug
pytestdebug.log
# Local config overrides
config.local.py
settings.local.py
# Vault / local dev secrets
.env.vault
vault.token
.env
.dockerignore
/sql

View File

@@ -14,12 +14,11 @@ class Settings(BaseSettings):
room_empty_timeout_seconds: int = Field(default=600, alias="ROOM_EMPTY_TIMEOUT_SECONDS") room_empty_timeout_seconds: int = Field(default=600, alias="ROOM_EMPTY_TIMEOUT_SECONDS")
max_attachment_size_mb: int = Field(default=100, alias="MAX_ATTACHMENT_SIZE_MB") max_attachment_size_mb: int = Field(default=100, alias="MAX_ATTACHMENT_SIZE_MB")
uploads_dir: str = Field(default="uploads", alias="UPLOADS_DIR") uploads_dir: str = Field(default="uploads", alias="UPLOADS_DIR")
telegram_alerting_enabled: bool = Field(default=False, alias="TELEGRAM_ALERTING_ENABLED")
telegram_bot_token: str = Field(default="", alias="TELEGRAM_BOT_TOKEN")
telegram_chat_id: str = Field(default="", alias="TELEGRAM_CHAT_ID")
telegram_topic_id: int | None = Field(default=None, alias="TELEGRAM_TOPIC_ID")
block_probe_paths: bool = Field(default=True, alias="BLOCK_PROBE_PATHS") block_probe_paths: bool = Field(default=True, alias="BLOCK_PROBE_PATHS")
cors_allow_origins: str = Field(default="", alias="CORS_ALLOW_ORIGINS") cors_allow_origins: str = Field(default="", alias="CORS_ALLOW_ORIGINS")
turn_urls: str = Field(default="", alias="TURN_URLS")
turn_username: str = Field(default="", alias="TURN_USERNAME")
turn_credential: str = Field(default="", alias="TURN_CREDENTIAL")
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore") model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
@@ -31,14 +30,23 @@ class Settings(BaseSettings):
def uploads_path(self) -> Path: def uploads_path(self) -> Path:
return Path(self.uploads_dir) return Path(self.uploads_dir)
@property
def telegram_alerting_available(self) -> bool:
return bool(self.telegram_alerting_enabled and self.telegram_bot_token.strip() and self.telegram_chat_id.strip())
@property @property
def cors_origins(self) -> list[str]: def cors_origins(self) -> list[str]:
raw = getattr(self, 'cors_allow_origins', '') raw = getattr(self, 'cors_allow_origins', '')
return [origin.strip() for origin in raw.split(',') if origin.strip()] return [origin.strip() for origin in raw.split(',') if origin.strip()]
@property
def turn_ice_servers(self) -> list[dict[str, str | list[str]]]:
urls = [url.strip() for url in self.turn_urls.split(',') if url.strip()]
if not urls or not self.turn_username.strip() or not self.turn_credential.strip():
return []
return [
{
'urls': urls,
'username': self.turn_username.strip(),
'credential': self.turn_credential.strip(),
}
]
settings = Settings() settings = Settings()

View File

@@ -1,9 +1,5 @@
import json
import mimetypes
import secrets import secrets
import urllib.parse import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from uuid import uuid4 from uuid import uuid4
@@ -72,180 +68,6 @@ def room_upload_dir(room_name: str) -> Path:
return settings.uploads_path / room_name return settings.uploads_path / room_name
def human_dt(value: datetime) -> str:
return value.astimezone().strftime("%d.%m.%Y %H:%M:%S %Z")
def format_duration(seconds: int) -> str:
minutes, secs = divmod(max(0, seconds), 60)
hours, minutes = divmod(minutes, 60)
if hours:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def split_text_chunks(text: str, limit: int = 3500) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
current = ""
for line in text.splitlines():
candidate = f"{current}\n{line}".strip() if current else line
if len(candidate) > limit and current:
chunks.append(current)
current = line
elif len(line) > limit:
if current:
chunks.append(current)
current = ""
for start in range(0, len(line), limit):
chunks.append(line[start:start + limit])
else:
current = candidate
if current:
chunks.append(current)
return chunks
def telegram_targets() -> list[dict[str, str | int]]:
chat_id = settings.telegram_chat_id.strip()
if settings.telegram_topic_id is not None:
return [
{
"chat_id": chat_id,
"message_thread_id": settings.telegram_topic_id,
}
]
return [{"chat_id": chat_id}]
def telegram_request(
method: str,
fields: dict[str, str | int],
file_path: Path | None = None,
file_name: str | None = None,
content_type: str | None = None,
) -> None:
url = f"https://api.telegram.org/bot{settings.telegram_bot_token.strip()}/{method}"
if file_path is None:
data = urllib.parse.urlencode({k: v for k, v in fields.items() if v is not None}).encode("utf-8")
request = urllib.request.Request(url, data=data, method="POST")
else:
boundary = f"----ChatGPTBoundary{uuid4().hex}"
body = bytearray()
for key, value in fields.items():
if value is None:
continue
body.extend(f"--{boundary}\r\n".encode())
body.extend(f'Content-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n'.encode("utf-8"))
guessed_type = content_type or mimetypes.guess_type(file_name or file_path.name)[0] or "application/octet-stream"
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="document"; filename="{file_name or file_path.name}"\r\n'.encode(
"utf-8"
)
)
body.extend(f"Content-Type: {guessed_type}\r\n\r\n".encode("utf-8"))
body.extend(file_path.read_bytes())
body.extend(b"\r\n")
body.extend(f"--{boundary}--\r\n".encode())
request = urllib.request.Request(
url,
data=bytes(body),
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode("utf-8"))
if not payload.get("ok"):
raise RuntimeError(payload.get("description") or "Telegram API error")
def send_telegram_text(text: str) -> None:
for target in telegram_targets():
for chunk in split_text_chunks(text):
telegram_request(
"sendMessage",
{
**target,
"text": chunk,
"disable_web_page_preview": "true",
},
)
def send_telegram_document(file_path: Path, attachment: AttachmentRecord, caption: str | None = None) -> None:
for target in telegram_targets():
telegram_request(
"sendDocument",
{
**target,
"caption": caption or attachment.original_name,
},
file_path=file_path,
file_name=attachment.original_name,
content_type=attachment.content_type,
)
def send_finish_alert(call: CallRecord) -> None:
if not (settings.telegram_alerting_available and call.telegram_alert_enabled):
return
started_at = call.started_at or call.created_at
finished_at = datetime.now(started_at.tzinfo)
duration_seconds = int((finished_at - started_at).total_seconds()) if started_at else 0
if duration_seconds < 60:
return
if len(call.participant_names) < 2:
return
if call.started_at is None:
return
participants = ", ".join(call.participant_names) if call.participant_names else ""
summary = "\n".join(
[
f"Итоги созвона: {call.room_title}",
f"Комната: {call.room_name}",
f"Создана: {human_dt(call.created_at)}",
f"Таймер созвона: {format_duration(duration_seconds)}",
f"Участники: {participants}",
]
)
send_telegram_text(summary)
transcript_lines: list[str] = []
for event in call.chat_events:
timestamp = event.created_at.astimezone().strftime("%H:%M:%S")
if event.event_type == "text" and event.text:
transcript_lines.append(f"[{timestamp}] {event.author}: {event.text}")
elif event.event_type == "file":
transcript_lines.append(f"[{timestamp}] {event.author}: файл {event.file_name or 'без названия'}")
if transcript_lines:
send_telegram_text("Сообщения и файлы из чата:\n" + "\n".join(transcript_lines))
else:
send_telegram_text("Сообщения и файлы из чата: нет пользовательских сообщений.")
attachment_authors = {
event.attachment_id: event.author
for event in call.chat_events
if event.event_type == "file" and event.attachment_id
}
upload_dir = room_upload_dir(call.room_name)
for attachment in call.attachments:
file_path = upload_dir / attachment.stored_name
if not file_path.exists():
continue
author = attachment_authors.get(attachment.attachment_id)
caption = f"Файл из чата: {attachment.original_name}"
if author:
caption = f"{caption}{author}"
send_telegram_document(file_path, attachment, caption=caption[:1000])
@app.get("/health") @app.get("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@@ -254,8 +76,8 @@ def health() -> dict[str, str]:
@app.get("/api/config", response_model=AppConfigResponse) @app.get("/api/config", response_model=AppConfigResponse)
def get_config() -> AppConfigResponse: def get_config() -> AppConfigResponse:
return AppConfigResponse( return AppConfigResponse(
telegram_alerting_available=settings.telegram_alerting_available,
max_attachment_size_mb=settings.max_attachment_size_mb, max_attachment_size_mb=settings.max_attachment_size_mb,
turn_ice_servers=settings.turn_ice_servers,
) )
@@ -278,7 +100,6 @@ async def create_call(payload: CreateCallRequest | None = None) -> CreateCallRes
invite_token = uuid4().hex invite_token = uuid4().hex
room_title = (payload.room_title if payload and payload.room_title else room_name) room_title = (payload.room_title if payload and payload.room_title else room_name)
password = payload.password.strip() if payload and payload.password else None password = payload.password.strip() if payload and payload.password else None
telegram_alert_enabled = bool(payload.telegram_alert_enabled) if payload else False
quick_join_default = bool(payload.quick_join) if payload else False quick_join_default = bool(payload.quick_join) if payload else False
short_code = secrets.token_hex(4) short_code = secrets.token_hex(4)
@@ -292,7 +113,6 @@ async def create_call(payload: CreateCallRequest | None = None) -> CreateCallRes
invite_short_code=short_code, invite_short_code=short_code,
quick_join_default=quick_join_default, quick_join_default=quick_join_default,
password=password or None, password=password or None,
telegram_alert_enabled=settings.telegram_alerting_available and telegram_alert_enabled,
) )
store.create(call) store.create(call)
await create_room_if_needed(room_name) await create_room_if_needed(room_name)
@@ -479,17 +299,9 @@ def finish_call(room_name: str, payload: FinishCallRequest) -> dict[str, str]:
if payload.invite_token != call.invite_token: if payload.invite_token != call.invite_token:
raise HTTPException(status_code=403, detail="Invalid invite token") raise HTTPException(status_code=403, detail="Invalid invite token")
alert_status = "disabled"
if settings.telegram_alerting_available and call.telegram_alert_enabled:
try:
send_finish_alert(call)
alert_status = "sent"
except Exception:
alert_status = "failed"
store.clear_attachments(room_name) store.clear_attachments(room_name)
upload_dir = room_upload_dir(room_name) upload_dir = room_upload_dir(room_name)
if upload_dir.exists(): if upload_dir.exists():
rmtree(upload_dir, ignore_errors=True) rmtree(upload_dir, ignore_errors=True)
store.deactivate(room_name) store.deactivate(room_name)
return {"status": "finished", "telegram_alert_status": alert_status} return {"status": "finished"}

View File

@@ -33,7 +33,6 @@ class CallRecord(BaseModel):
invite_short_code: str | None = None invite_short_code: str | None = None
quick_join_default: bool = False quick_join_default: bool = False
password: str | None = None password: str | None = None
telegram_alert_enabled: bool = False
created_at: datetime = Field(default_factory=utcnow) created_at: datetime = Field(default_factory=utcnow)
started_at: datetime | None = None started_at: datetime | None = None
is_active: bool = True is_active: bool = True

View File

@@ -6,7 +6,6 @@ from pydantic import BaseModel, Field
class CreateCallRequest(BaseModel): class CreateCallRequest(BaseModel):
room_title: str = Field(default="", max_length=100) room_title: str = Field(default="", max_length=100)
password: str = Field(default="", max_length=100) password: str = Field(default="", max_length=100)
telegram_alert_enabled: bool = False
quick_join: bool = False quick_join: bool = False
@@ -46,8 +45,8 @@ class CallInfoResponse(BaseModel):
class AppConfigResponse(BaseModel): class AppConfigResponse(BaseModel):
telegram_alerting_available: bool
max_attachment_size_mb: int max_attachment_size_mb: int
turn_ice_servers: list[dict] = Field(default_factory=list)
class FinishCallRequest(BaseModel): class FinishCallRequest(BaseModel):

View File

@@ -7,7 +7,7 @@ services:
ports: ports:
- "7881:7881/tcp" - "7881:7881/tcp"
- "7882:7882/udp" - "7882:7882/udp"
- "127.0.0.1:7880:7880" - "7880:7880"
volumes: volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro - ./livekit.yaml:/etc/livekit.yaml:ro
networks: networks:
@@ -40,7 +40,7 @@ services:
container_name: linkra-frontend container_name: linkra-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:8080:80" - "2080:80"
depends_on: depends_on:
- backend - backend
networks: networks:

View File

@@ -4,6 +4,8 @@
<meta charset='UTF-8' /> <meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' /> <meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' />
<meta name='description' content='Linkra: видеозвонки по ссылке в браузере.' /> <meta name='description' content='Linkra: видеозвонки по ссылке в браузере.' />
<link rel='icon' type='image/png' href='/icons/favicon.png' />
<link rel='apple-touch-icon' href='/icons/favicon.png' />
<title>Linkra</title> <title>Linkra</title>
</head> </head>
<body> <body>

View File

@@ -21,29 +21,6 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)"
}, },
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -5,7 +5,13 @@ import QRCode from'qrcode';
import'./styles.css'; import'./styles.css';
const TELEGRAM_PREF_KEY = 'linkra-pref-telegram-alert'; const ICONS = {
attachment: '/icons/attachment.png',
cameraOff: '/icons/camera_off.png',
cameraOn: '/icons/camera_on.png',
micOff: '/icons/micro_off.png',
micOn: '/icons/micro_on.png'
};
function apiUrl(path) { function apiUrl(path) {
@@ -103,9 +109,6 @@ function ErrorPage({ code }) {
function HomePage({ showToast }) { function HomePage({ showToast }) {
const [config, setConfig] = useState({ telegram_alerting_available: false, max_attachment_size_mb: 100 });
const [settingsOpen, setSettingsOpen] = useState(false);
const [telegramAlert, setTelegramAlert] = useState(() => localStorage.getItem(TELEGRAM_PREF_KEY) === '1');
const [roomTitle, setRoomTitle] = useState(''); const [roomTitle, setRoomTitle] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -115,13 +118,8 @@ function HomePage({ showToast }) {
useEffect(() => { useEffect(() => {
document.title = 'Созвон по ссылке · Linkra'; document.title = 'Созвон по ссылке · Linkra';
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
}, []); }, []);
useEffect(() => {
localStorage.setItem(TELEGRAM_PREF_KEY, telegramAlert ? '1' : '0');
}, [telegramAlert]);
useEffect(() => { useEffect(() => {
if (!result || !qrRef.current) return; if (!result || !qrRef.current) return;
const target = result.short_invite_link || result.invite_link; const target = result.short_invite_link || result.invite_link;
@@ -149,7 +147,6 @@ function HomePage({ showToast }) {
body: JSON.stringify({ body: JSON.stringify({
room_title: title, room_title: title,
password: roomPassword, password: roomPassword,
telegram_alert_enabled: config.telegram_alerting_available && telegramAlert,
quick_join: quickJoin quick_join: quickJoin
}) })
}); });
@@ -205,19 +202,6 @@ function HomePage({ showToast }) {
<section className='card home-card'> <section className='card home-card'>
<div className='home-card-header'> <div className='home-card-header'>
<h2>Новая комната</h2> <h2>Новая комната</h2>
<div className='settings-wrap'>
<button className='icon-btn small' type='button' onClick={() => setSettingsOpen((value) => !value)}></button>
{settingsOpen ? (
<div className='settings-menu'>
{config.telegram_alerting_available ? (
<label className='checkbox-line'>
<input type='checkbox' checked={telegramAlert} onChange={(event) => setTelegramAlert(event.target.checked)} />
<span>Итоги созвона в Telegram</span>
</label>
) : <p className='muted'>Итоги в Telegram недоступны на сервере</p>}
</div>
) : null}
</div>
</div> </div>
{!result ? ( {!result ? (
@@ -262,7 +246,7 @@ function HomePage({ showToast }) {
} }
function MediaTrack({ track, kind, muted = false, className = '' }) { function MediaTrack({ track, muted = false, className = '' }) {
const ref = useRef(null); const ref = useRef(null);
useEffect(() => { useEffect(() => {
@@ -273,16 +257,49 @@ function MediaTrack({ track, kind, muted = false, className = '' }) {
}; };
}, [track]); }, [track]);
if (kind === 'audio') return <audio ref={ref} autoPlay playsInline muted={muted} className={className} />;
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />; return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
} }
function VideoTile({ item, active }) { function RemoteAudioTrack({ track, volume }) {
const gainRef = useRef(null);
useEffect(() => {
if (!track?.mediaStreamTrack) return undefined;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return undefined;
const context = new AudioContextClass();
const stream = new MediaStream([track.mediaStreamTrack]);
const source = context.createMediaStreamSource(stream);
const gain = context.createGain();
gain.gain.value = volume;
source.connect(gain);
gain.connect(context.destination);
gainRef.current = gain;
context.resume().catch(() => {});
return () => {
source.disconnect();
gain.disconnect();
gainRef.current = null;
context.close().catch(() => {});
};
}, [track]);
useEffect(() => {
if (gainRef.current) gainRef.current.gain.value = volume;
}, [volume]);
return null;
}
function VideoTile({ item, active, volume, onVolumeChange }) {
const camera = item.tracks.find((entry) => entry.kind === 'video' && entry.source !== Track.Source.ScreenShare); const camera = item.tracks.find((entry) => entry.kind === 'video' && entry.source !== Track.Source.ScreenShare);
const screen = item.tracks.find((entry) => entry.kind === 'video' && entry.source === Track.Source.ScreenShare); const screen = item.tracks.find((entry) => entry.kind === 'video' && entry.source === Track.Source.ScreenShare);
const audio = item.tracks.filter((entry) => entry.kind === 'audio'); const audio = item.tracks.filter((entry) => entry.kind === 'audio');
const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?'; const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?';
const volumePercent = Math.round(volume * 100);
return ( return (
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}> <div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
@@ -290,14 +307,28 @@ function VideoTile({ item, active }) {
<span>{item.name}</span> <span>{item.name}</span>
<span className='muted'>{item.isLocal ? 'ты' : ''}</span> <span className='muted'>{item.isLocal ? 'ты' : ''}</span>
</div> </div>
{!item.isLocal ? (
<label className='participant-volume'>
<span>Громкость: {volumePercent}%</span>
<input
aria-label={`Громкость участника ${item.name}`}
max='2'
min='0'
onChange={(event) => onVolumeChange(item.identity, Number(event.target.value))}
step='0.05'
type='range'
value={volume}
/>
</label>
) : null}
{screen ? ( {screen ? (
<div className='screen-slot'> <div className='screen-slot'>
<MediaTrack track={screen.track} kind='video' className='screen-share-video' muted={item.isLocal} /> <MediaTrack track={screen.track} className='screen-share-video' muted={item.isLocal} />
</div> </div>
) : null} ) : null}
<div className='media-slot'> <div className='media-slot'>
{camera ? <MediaTrack track={camera.track} kind='video' className={item.isLocal ? 'local-video' : ''} muted={item.isLocal} /> : <div className='avatar-placeholder'>{initial}</div>} {camera ? <MediaTrack track={camera.track} className={item.isLocal ? 'local-video' : ''} muted={item.isLocal} /> : <div className='avatar-placeholder'>{initial}</div>}
{audio.map((entry) => item.isLocal ? null : <MediaTrack key={entry.id} track={entry.track} kind='audio' className='hidden-audio' />)} {audio.map((entry) => item.isLocal ? null : <RemoteAudioTrack key={entry.id} track={entry.track} volume={volume} />)}
</div> </div>
</div> </div>
); );
@@ -314,10 +345,11 @@ function CallPage({ roomName, showToast }) {
const [joining, setJoining] = useState(false); const [joining, setJoining] = useState(false);
const [room, setRoom] = useState(null); const [room, setRoom] = useState(null);
const [participants, setParticipants] = useState([]); const [participants, setParticipants] = useState([]);
const [participantVolumes, setParticipantVolumes] = useState({});
const [activeSpeakers, setActiveSpeakers] = useState([]); const [activeSpeakers, setActiveSpeakers] = useState([]);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [chatText, setChatText] = useState(''); const [chatText, setChatText] = useState('');
const [config, setConfig] = useState({ max_attachment_size_mb: 100 }); const [config, setConfig] = useState({ max_attachment_size_mb: 100, turn_ice_servers: [] });
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const previewRef = useRef(null); const previewRef = useRef(null);
const previewStreamRef = useRef(null); const previewStreamRef = useRef(null);
@@ -431,6 +463,14 @@ function CallPage({ roomName, showToast }) {
await room.localParticipant.publishData(encoded, { reliable: true }); await room.localParticipant.publishData(encoded, { reliable: true });
}; };
const updateParticipantVolume = useCallback((identity, volume) => {
const safeVolume = Math.min(2, Math.max(0, volume));
setParticipantVolumes((current) => ({
...current,
[identity]: safeVolume
}));
}, []);
const joinCall = async () => { const joinCall = async () => {
const name = displayName.trim(); const name = displayName.trim();
if (!name) { if (!name) {
@@ -493,7 +533,13 @@ function CallPage({ roomName, showToast }) {
setRoom(null); setRoom(null);
setParticipants([]); setParticipants([]);
}); });
await nextRoom.connect(data.server_url, data.participant_token); const connectOptions = {};
if (config.turn_ice_servers?.length) {
connectOptions.rtcConfig = {
iceServers: config.turn_ice_servers
};
}
await nextRoom.connect(data.server_url, data.participant_token, connectOptions);
await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled); await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled);
await nextRoom.localParticipant.setCameraEnabled(cameraEnabled); await nextRoom.localParticipant.setCameraEnabled(cameraEnabled);
setRoom(nextRoom); setRoom(nextRoom);
@@ -647,8 +693,14 @@ function CallPage({ roomName, showToast }) {
</label> </label>
) : null} ) : null}
<div className='row'> <div className='row'>
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</button> <button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</button> <img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
<span>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</span>
</button>
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
<span>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</span>
</button>
</div> </div>
<button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button> <button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button>
</div> </div>
@@ -664,15 +716,27 @@ function CallPage({ roomName, showToast }) {
<p className='muted'>В комнате: {participants.length}</p> <p className='muted'>В комнате: {participants.length}</p>
</div> </div>
<div className='meeting-controls'> <div className='meeting-controls'>
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic}>Mic</button> <button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic} aria-label={micEnabled ? 'Выключить микрофон' : 'Включить микрофон'}>
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera}>Cam</button> <img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
</button>
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera} aria-label={cameraEnabled ? 'Выключить камеру' : 'Включить камеру'}>
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
</button>
<button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button> <button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button>
<button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button> <button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button>
<div className='meeting-timer'>{time}</div> <div className='meeting-timer'>{time}</div>
</div> </div>
</div> </div>
<div className='videos'> <div className='videos'>
{participants.map((item) => <VideoTile key={item.identity} item={item} active={activeSpeakers.includes(item.identity)} />)} {participants.map((item) => (
<VideoTile
active={activeSpeakers.includes(item.identity)}
item={item}
key={item.identity}
onVolumeChange={updateParticipantVolume}
volume={participantVolumes[item.identity] ?? 1}
/>
))}
</div> </div>
</section> </section>
@@ -690,7 +754,10 @@ function CallPage({ roomName, showToast }) {
</div> </div>
<div className='chat-form'> <div className='chat-form'>
<input value={chatText} onChange={(event) => setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' /> <input value={chatText} onChange={(event) => setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' />
<input ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} /> <input className='hidden-file-input' ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} />
<button className='icon-btn attachment-btn' type='button' onClick={() => fileRef.current?.click()} aria-label='Прикрепить файл'>
<img className='control-icon' src={ICONS.attachment} alt='' />
</button>
<button className='btn primary' type='button' onClick={sendChat}>Отправить</button> <button className='btn primary' type='button' onClick={sendChat}>Отправить</button>
</div> </div>
</aside> </aside>

View File

@@ -138,6 +138,11 @@ input {
font: inherit; font: inherit;
} }
input[type='range'] {
padding: 0;
accent-color: var(--primary);
}
.btn, .btn,
.icon-btn, .icon-btn,
.toggle-btn { .toggle-btn {
@@ -180,6 +185,9 @@ input {
min-height: 50px; min-height: 50px;
background: #1f2937; background: #1f2937;
color: white; color: white;
display: inline-flex;
align-items: center;
justify-content: center;
} }
.icon-btn.small { .icon-btn.small {
@@ -205,6 +213,25 @@ input {
border: 1px solid #4b5563; border: 1px solid #4b5563;
} }
.toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.control-icon {
width: 24px;
height: 24px;
object-fit: contain;
display: block;
}
.icon-btn .control-icon {
width: 26px;
height: 26px;
}
.home-actions { .home-actions {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
@@ -382,6 +409,16 @@ input {
justify-content: space-between; justify-content: space-between;
} }
.participant-volume {
display: grid;
gap: 8px;
padding: 10px 12px 12px;
border-bottom: 1px solid var(--line);
background: rgba(15, 23, 42, 0.72);
color: var(--muted);
font-size: 13px;
}
.screen-slot { .screen-slot {
height: 260px; height: 260px;
background: #020617; background: #020617;
@@ -473,10 +510,24 @@ input {
.chat-form { .chat-form {
margin-top: 14px; margin-top: 14px;
display: grid; display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
} }
.chat-form input[type='text'] {
min-width: 220px;
flex: 1 1 260px;
}
.hidden-file-input {
display: none;
}
.attachment-btn {
flex: 0 0 auto;
}
.file-link { .file-link {
color: #93c5fd; color: #93c5fd;
} }