ПОШЛО НАХУЙ КТО ЭТИ ЗВОВНКИ ДЕЛАЛ

This commit is contained in:
Jganenokk
2026-07-29 19:10:03 +07:00
parent f4f9e22be8
commit 0e51038051
17 changed files with 2562 additions and 217 deletions
@@ -0,0 +1,510 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/calls/call_admin.dart';
import '../../../core/calls/call_session.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/prompt_dialog.dart';
import '../../widgets/sheet_helpers.dart';
class CallParticipantView {
final String name;
final String? avatarUrl;
const CallParticipantView({required this.name, this.avatarUrl});
}
typedef CallParticipantResolver =
CallParticipantView Function(CallParticipant participant);
Future<void> showCallParticipantsSheet(
BuildContext context, {
required CallSession session,
required ColorScheme scheme,
required CallParticipantResolver resolve,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: scheme.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => Theme(
data: Theme.of(context).copyWith(colorScheme: scheme),
child: _ParticipantsSheet(session: session, resolve: resolve),
),
);
}
class _ParticipantsSheet extends StatefulWidget {
final CallSession session;
final CallParticipantResolver resolve;
const _ParticipantsSheet({required this.session, required this.resolve});
@override
State<_ParticipantsSheet> createState() => _ParticipantsSheetState();
}
class _ParticipantsSheetState extends State<_ParticipantsSheet> {
final Map<CallOption, bool> _options = {};
final Map<CallFeature, Set<CallRoleName>> _features = {};
bool _recording = false;
StreamSubscription<void>? _infoSub;
@override
void initState() {
super.initState();
_infoSub = widget.session.infoUpdates.listen((_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_infoSub?.cancel();
super.dispose();
}
CallParticipant? get _self {
for (final p in widget.session.participants) {
if (p.isSelf) return p;
}
return null;
}
Future<bool> _run(Future<void> Function(CallAdmin admin) action) async {
final admin = widget.session.admin;
if (admin == null) {
showCustomNotification(context, 'Нет связи с сервером звонка');
return false;
}
try {
await action(admin);
return true;
} catch (e) {
if (mounted) showCustomNotification(context, 'Не удалось: $e');
return false;
}
}
CallParticipantRef _ref(CallParticipant p) => CallParticipantRef(p.id);
void _participantActions(CallParticipant p) {
final cs = Theme.of(context).colorScheme;
final view = widget.resolve(p);
final isAdmin = p.isAdmin;
final isSpeaker = p.isSpeaker;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
view.name,
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(
p.roles.isEmpty ? 'Участник' : p.roles.join(' · '),
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
),
_action(cs, Symbols.mic_off, 'Выключить микрофон', () {
Navigator.pop(sheetContext);
_run((a) => a.muteMicrophone(_ref(p)));
}),
_action(cs, Symbols.videocam_off, 'Запросить камеру', () {
Navigator.pop(sheetContext);
_run(
(a) =>
a.requestMedia({CallMedia.video}, participant: _ref(p)),
);
}),
_action(
cs,
isAdmin ? Symbols.remove_moderator : Symbols.shield_person,
isAdmin ? 'Снять администратора' : 'Назначить администратором',
() {
Navigator.pop(sheetContext);
_run(
(a) => a.setRoles(_ref(p), [
CallRoleName.admin,
], revoke: isAdmin),
);
},
),
_action(
cs,
isSpeaker ? Symbols.voice_over_off : Symbols.record_voice_over,
isSpeaker ? 'Убрать из спикеров' : 'Сделать спикером',
() {
Navigator.pop(sheetContext);
_run(
(a) => a.setRoles(_ref(p), [
CallRoleName.speaker,
], revoke: isSpeaker),
);
},
),
_action(cs, Symbols.arrow_upward, 'Повысить (promote)', () {
Navigator.pop(sheetContext);
_run((a) => a.setPromoted(_ref(p), true));
}),
_action(cs, Symbols.arrow_downward, 'Понизить (demote)', () {
Navigator.pop(sheetContext);
_run((a) => a.setPromoted(_ref(p), false));
}),
_action(cs, Symbols.push_pin, 'Закрепить', () {
Navigator.pop(sheetContext);
_run((a) => a.setPinned(_ref(p), true));
}),
_action(cs, Symbols.keep_off, 'Открепить', () {
Navigator.pop(sheetContext);
_run((a) => a.setPinned(_ref(p), false));
}),
_action(cs, Symbols.person_remove, 'Удалить из звонка', () {
Navigator.pop(sheetContext);
_run((a) => a.removeParticipant(_ref(p)));
}, destructive: true),
const SizedBox(height: 8),
],
),
),
),
);
}
void _showOptions() {
final cs = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => Theme(
data: Theme.of(context).copyWith(colorScheme: cs),
child: StatefulBuilder(
builder: (_, setSheet) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_sheetTitle(cs, 'Настройки звонка'),
for (final option in CallOption.values)
SwitchListTile(
value: _options[option] ?? false,
title: Text(
_optionLabel(option),
style: TextStyle(color: cs.onSurface, fontSize: 15),
),
subtitle: Text(
option.wire,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
),
),
onChanged: (value) async {
setSheet(() => _options[option] = value);
final ok = await _run(
(a) => a.setOptions({option: value}),
);
if (!ok) setSheet(() => _options[option] = !value);
},
),
const SizedBox(height: 8),
],
),
),
),
),
),
);
}
void _showFeatures() {
final cs = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: cs.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => Theme(
data: Theme.of(context).copyWith(colorScheme: cs),
child: StatefulBuilder(
builder: (_, setSheet) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_sheetTitle(cs, 'Кому доступны функции'),
for (final feature in CallFeature.values)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_featureLabel(feature),
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: [
for (final role in CallRoleName.values)
FilterChip(
label: Text(role.wire),
selected:
_features[feature]?.contains(role) ??
false,
onSelected: (selected) {
final set = _features.putIfAbsent(
feature,
() => <CallRoleName>{},
);
setSheet(() {
selected
? set.add(role)
: set.remove(role);
});
_run(
(a) => a.enableFeatureForRoles(
feature,
set.toList(),
),
);
},
),
],
),
],
),
),
const SizedBox(height: 8),
],
),
),
),
),
),
);
}
Future<void> _addByLink() async {
final link = await showTextInputDialog(
context,
title: 'Добавить участника',
description: 'Ссылка-приглашение участника',
confirmLabel: 'Добавить',
);
if (link == null || link.trim().isEmpty || !mounted) return;
await _run((a) => a.addParticipantByLink(link.trim()));
}
String _optionLabel(CallOption option) => switch (option) {
CallOption.requireAuthToJoin => 'Только авторизованные',
CallOption.waitingHall => 'Зал ожидания',
CallOption.recurring => 'Повторяющийся звонок',
CallOption.feedback => 'Сбор отзывов',
CallOption.audienceMode => 'Режим зрителей',
CallOption.asr => 'Расшифровка речи',
CallOption.waitForAdmin => 'Ждать администратора',
CallOption.adminIsHere => 'Администратор на месте',
};
String _featureLabel(CallFeature feature) => switch (feature) {
CallFeature.addParticipant => 'Добавлять участников',
CallFeature.admin => 'Права администратора',
CallFeature.asr => 'Расшифровка речи',
CallFeature.movieShare => 'Совместный просмотр',
CallFeature.record => 'Запись звонка',
CallFeature.speaker => 'Быть спикером',
};
Widget _sheetTitle(ColorScheme cs, String text) => Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
text,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
);
Widget _action(
ColorScheme cs,
IconData icon,
String label,
VoidCallback onTap, {
bool destructive = false,
}) {
final color = destructive ? cs.error : cs.onSurface;
return ListTile(
leading: Icon(icon, color: color),
title: Text(label, style: TextStyle(color: color, fontSize: 16)),
onTap: onTap,
);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final participants = widget.session.participants;
final self = _self;
final handRaised = self?.handRaised ?? false;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_sheetTitle(cs, 'Участники · ${participants.length}'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_chip(cs, Symbols.mic_off, 'Заглушить всех', () {
_run((a) => a.muteEveryone());
}),
_chip(cs, Symbols.do_not_touch, 'Опустить руки', () {
_run((a) => a.lowerAllHands());
}),
_chip(
cs,
handRaised ? Symbols.back_hand : Symbols.front_hand,
handRaised ? 'Опустить руку' : 'Поднять руку',
() => _run((a) => a.setHandRaised(!handRaised)),
active: handRaised,
),
_chip(
cs,
_recording
? Symbols.stop_circle
: Symbols.radio_button_checked,
_recording ? 'Остановить запись' : 'Начать запись',
() async {
final next = !_recording;
setState(() => _recording = next);
final ok = await _run(
(a) => next
? a.startRecord(name: 'Запись звонка')
: a.stopRecord(),
);
if (!ok && mounted) setState(() => _recording = !next);
},
active: _recording,
),
_chip(cs, Symbols.tune, 'Настройки', _showOptions),
_chip(cs, Symbols.shield_person, 'Права ролей', _showFeatures),
_chip(cs, Symbols.person_add, 'Добавить по ссылке', _addByLink),
],
),
),
const SizedBox(height: 12),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: participants.length,
itemBuilder: (_, i) => _tile(cs, participants[i]),
),
),
const SizedBox(height: 8),
],
),
);
}
Widget _chip(
ColorScheme cs,
IconData icon,
String label,
VoidCallback onTap, {
bool active = false,
}) {
return ActionChip(
avatar: Icon(
icon,
size: 18,
color: active ? cs.onPrimary : cs.onSurfaceVariant,
),
label: Text(label),
labelStyle: TextStyle(color: active ? cs.onPrimary : cs.onSurface),
backgroundColor: active ? cs.primary : cs.surfaceContainerHighest,
side: BorderSide.none,
onPressed: onTap,
);
}
Widget _tile(ColorScheme cs, CallParticipant p) {
final view = widget.resolve(p);
final subtitle = <String>[
if (p.isCreator) 'Создатель' else if (p.isAdmin) 'Администратор',
if (p.isSpeaker) 'Спикер',
if (p.handRaised) 'Поднял руку',
];
return ListTile(
leading: KometAvatar(name: view.name, imageUrl: view.avatarUrl, size: 40),
title: Text(
view.name,
style: TextStyle(color: cs.onSurface, fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: subtitle.isEmpty
? null
: Text(
subtitle.join(' · '),
style: TextStyle(color: cs.primary, fontSize: 13),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (p.screenSharing)
Icon(Symbols.screen_share, size: 18, color: cs.primary),
if (p.videoEnabled)
Icon(Symbols.videocam, size: 18, color: cs.onSurfaceVariant),
Icon(
p.audioEnabled ? Symbols.mic : Symbols.mic_off,
size: 18,
color: p.audioEnabled ? cs.onSurfaceVariant : cs.error,
),
],
),
onTap: p.isSelf ? null : () => _participantActions(p),
);
}
}
+66 -10
View File
@@ -25,6 +25,7 @@ import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/sheet_helpers.dart';
import '../../widgets/small_spinner.dart';
import 'call_participants_sheet.dart';
import 'komet_hub.dart';
const Color _kEndRed = Color(0xFFE5484D);
@@ -333,6 +334,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
await WidgetsBinding.instance.endOfFrame;
try {
await session.setVideoEnabled(!session.localVideo);
} catch (e) {
if (mounted) showCustomNotification(context, 'Камера недоступна: $e');
} finally {
_syncLocalPreview();
if (mounted) setState(() => _videoBusy = false);
@@ -346,6 +349,10 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
await WidgetsBinding.instance.endOfFrame;
try {
await session.setScreenSharing(!session.localScreen);
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Трансляция не запустилась: $e');
}
} finally {
_syncLocalPreview();
if (mounted) setState(() => _videoBusy = false);
@@ -367,13 +374,40 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
_remoteStreamSub?.cancel();
_dotsController.dispose();
_videoController.dispose();
_remoteRenderer.srcObject = null;
if (_rendererReady) _remoteRenderer.srcObject = null;
_remoteRenderer.dispose();
_localRenderer.srcObject = null;
if (_localRendererReady) _localRenderer.srcObject = null;
_localRenderer.dispose();
super.dispose();
}
void _showParticipants() {
final session = _session;
if (session == null) return;
final l10n = AppLocalizations.of(context)!;
showCallParticipantsSheet(
context,
session: session,
scheme: _darkScheme(context),
resolve: (p) {
if (p.isSelf) {
return CallParticipantView(
name: l10n.callParticipantYou,
avatarUrl: _avatarUrl,
);
}
final ext = p.externalId;
final info = ext != null ? _peerInfo[ext] : null;
return CallParticipantView(
name: info?.name?.isNotEmpty == true
? info!.name!
: l10n.callParticipantFallback,
avatarUrl: info?.avatar,
);
},
);
}
void _showInfoSheet() {
final cs = _darkScheme(context);
showModalBottomSheet<void>(
@@ -520,9 +554,29 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
InkWell(
onTap: count > 0 ? _showParticipants : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
subtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
if (count > 0) ...[
const SizedBox(width: 4),
Icon(
Symbols.chevron_right,
size: 16,
color: cs.onSurfaceVariant,
),
],
],
),
),
),
],
),
@@ -927,7 +981,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
final session = _session;
if (session == null) return null;
final pills = <Widget>[
if (session.peerMuted) _statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
if (session.peerMuted)
_statePill(cs, Symbols.mic_off, l10n.callPeerMicOff),
if (session.peerVideo)
_statePill(cs, Symbols.videocam, l10n.callPeerCameraOn),
];
@@ -1346,7 +1401,10 @@ class _CallInfoSheet extends StatelessWidget {
add(l10n.callInfoCountry, incoming?.country);
final isContact = incoming?.isContact;
if (isContact != null) {
add(l10n.callInfoInContacts, isContact ? l10n.callValueYes : l10n.callValueNo);
add(
l10n.callInfoInContacts,
isContact ? l10n.callValueYes : l10n.callValueNo,
);
}
add(l10n.callInfoPeerIp, info?.peerIp);
add(l10n.callInfoPeerNetwork, info?.peerNetwork);
@@ -1378,9 +1436,7 @@ class _CallInfoSheet extends StatelessWidget {
final vtracks = renderer.srcObject?.getVideoTracks().length ?? 0;
add(
l10n.callInfoVideoTrack,
vtracks > 0
? l10n.callInfoVideoTrackPresent(vtracks)
: l10n.callValueNo,
vtracks > 0 ? l10n.callInfoVideoTrackPresent(vtracks) : l10n.callValueNo,
);
final w = renderer.value.width.toInt();
final h = renderer.value.height.toInt();
+121 -20
View File
@@ -1,12 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart' show api, accountModule;
import '../../../backend/modules/account.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../core/calls/call_controller.dart';
import '../../../core/calls/call_session.dart';
import '../../../backend/modules/calls.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
@@ -14,6 +16,8 @@ import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/chat_menu_overlay.dart';
import '../../widgets/small_spinner.dart';
import '../../widgets/prompt_dialog.dart';
import '../../widgets/call_link_handler.dart';
import 'call_screen.dart';
class CallsTab extends StatefulWidget {
@@ -330,6 +334,102 @@ class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
}
}
Widget _buildLinkAction(
ColorScheme cs, {
required IconData icon,
required String label,
required VoidCallback onTap,
bool alignEnd = false,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Row(
mainAxisAlignment: alignEnd
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
Icon(icon, color: cs.primary, size: 24),
const SizedBox(width: 12),
Flexible(
child: Text(
label,
style: TextStyle(
color: cs.primary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Future<void> _createGroupCall() async {
final controller = CallController.instance;
if (controller.isBusy) {
showCustomNotification(context, 'Звонок уже идёт');
return;
}
final navigator = Navigator.of(context);
({CallSession session, String? joinLink}) created;
try {
created = await controller.createGroupCall();
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Не удалось создать звонок: $e');
}
return;
}
if (!mounted) return;
final link = created.joinLink;
if (link != null) {
await Clipboard.setData(ClipboardData(text: link));
if (!mounted) return;
showCustomNotification(context, 'Ссылка на звонок скопирована');
}
await navigator.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: 'Групповой звонок',
session: created.session,
isGroup: true,
),
),
);
}
Future<void> _joinGroupCall() async {
if (CallController.instance.isBusy) {
showCustomNotification(context, 'Звонок уже идёт');
return;
}
final url = await showTextInputDialog(
context,
title: 'Присоединиться к звонку',
description: 'Вставьте ссылку-приглашение',
hint: 'https://max.ru/joincall/...',
confirmLabel: 'Присоединиться',
keyboardType: TextInputType.url,
);
if (url == null || url.trim().isEmpty || !mounted) return;
final handled = await tryHandleCallLink(context, url.trim());
if (!handled && mounted) {
showCustomNotification(context, 'Это не ссылка на звонок');
}
}
Widget _buildTabItem(String label, int index, ColorScheme cs) {
final isSelected = _selectedTabIndex == index;
return GestureDetector(
@@ -393,27 +493,28 @@ class _CallsTabState extends State<CallsTab> with ReloadOnReconnect {
],
),
),
InkWell(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
children: [
Icon(Symbols.link, color: cs.primary, size: 24),
const SizedBox(width: 16),
Text(
'Создать групповой звонок',
style: TextStyle(
color: cs.primary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: _buildLinkAction(
cs,
icon: Symbols.link,
label: 'Создать звонок',
onTap: _createGroupCall,
),
],
),
),
Expanded(
child: _buildLinkAction(
cs,
icon: Symbols.group_add,
label: 'Присоединиться',
onTap: _joinGroupCall,
alignEnd: true,
),
),
],
),
),
Padding(
+3 -1
View File
@@ -550,7 +550,9 @@ class _CheckersViewState extends State<_CheckersView> {
final l10n = AppLocalizations.of(context)!;
final w = _result;
if (w != null) return w == _me ? l10n.hubCheckersWon : l10n.hubCheckersLost;
return _turn == _me ? l10n.hubCheckersYourMove : l10n.hubCheckersOpponentMove;
return _turn == _me
? l10n.hubCheckersYourMove
: l10n.hubCheckersOpponentMove;
}
Widget _boardWidget(ColorScheme cs) {