part of 'api_service.dart'; extension ApiServiceMedia on ApiService { /// Обновляет имя/фамилию/описание профиля через сервер (opcode 16) /// и возвращает обновленный профиль из ответа. Future updateProfileText( String firstName, String lastName, String description, ) async { try { await waitUntilOnline(); final Map payload = { "firstName": firstName, "lastName": lastName, }; if (description.isNotEmpty) { payload["description"] = description; } final int seq = _sendMessage(16, payload); _log('➡️ SEND: opcode=16, payload=$payload'); // Ждем ответ именно на этот seq с opcode 16 final response = await messages.firstWhere( (msg) => msg['seq'] == seq && msg['opcode'] == 16, ); final Map? respPayload = response['payload'] as Map?; if (respPayload == null) { throw Exception('Пустой ответ сервера на изменение профиля'); } // Обработка ошибок вида { error, localizedMessage, message, title } if (respPayload.containsKey('error')) { final humanMessage = respPayload['localizedMessage'] ?? respPayload['message'] ?? respPayload['title'] ?? respPayload['error']; throw Exception(humanMessage.toString()); } final profileJson = respPayload['profile']; if (profileJson is Map) { // Обновляем глобальный снапшот чатов/профиля, // чтобы все экраны сразу видели новые данные. _lastChatsPayload ??= { 'chats': [], 'contacts': [], 'profile': null, 'presence': null, 'config': null, }; _lastChatsPayload!['profile'] = profileJson; return Profile.fromJson(profileJson); } } catch (e) { _log('❌ Ошибка при обновлении профиля через opcode 16: $e'); } return null; } /// Загружает фото и привязывает его к профилю через opcode 80 + 16. /// Возвращает обновленный профиль из ответа opcode 16. Future updateProfilePhoto(String firstName, String lastName) async { try { final picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.gallery); if (image == null) return null; print("Запрашиваем URL для загрузки фото..."); final int seq = _sendMessage(80, {"count": 1}); final response = await messages.firstWhere((msg) => msg['seq'] == seq); final String uploadUrl = response['payload']['url']; print("URL получен: $uploadUrl"); print("Загружаем фото на сервер..."); var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', image.path)); var streamedResponse = await request.send(); var httpResponse = await http.Response.fromStream(streamedResponse); if (httpResponse.statusCode != 200) { throw Exception("Ошибка загрузки фото: ${httpResponse.body}"); } final uploadResult = jsonDecode(httpResponse.body); final String photoToken = uploadResult['photos'].values.first['token']; print("Фото загружено, получен токен: $photoToken"); print("Привязываем фото к профилю..."); final payload = { "firstName": firstName, "lastName": lastName, "photoToken": photoToken, "avatarType": "USER_AVATAR", }; final int seq16 = _sendMessage(16, payload); print("Запрос на смену аватара отправлен."); // Ждем ответ opcode 16 с обновленным профилем final resp16 = await messages.firstWhere( (msg) => msg['seq'] == seq16 && msg['opcode'] == 16, ); final Map? respPayload16 = resp16['payload'] as Map?; if (respPayload16 == null) { throw Exception('Пустой ответ сервера на смену аватара'); } if (respPayload16.containsKey('error')) { final humanMessage = respPayload16['localizedMessage'] ?? respPayload16['message'] ?? respPayload16['title'] ?? respPayload16['error']; throw Exception(humanMessage.toString()); } final profileJson = respPayload16['profile']; if (profileJson is Map) { _lastChatsPayload ??= { 'chats': [], 'contacts': [], 'profile': null, 'presence': null, 'config': null, }; _lastChatsPayload!['profile'] = profileJson; final profile = Profile.fromJson(profileJson); await ProfileCacheService().syncWithServerProfile(profile); return profile; } } catch (e) { print("!!! Ошибка в процессе смены аватара: $e"); } return null; } /// Загружает список заготовленных аватаров (opcode 25). /// Возвращает payload вида: /// { currentPresetId: int, presetAvatars: [ { name, avatars: [ {url,id}, ...] }, ... ] } Future> fetchPresetAvatars() async { await waitUntilOnline(); final int seq = _sendMessage(25, {}); _log('➡️ SEND: opcode=25, payload={}'); final resp = await messages.firstWhere( (msg) => msg['seq'] == seq && msg['opcode'] == 25, ); final payload = resp['payload'] as Map?; return payload ?? {}; } /// Выбирает один из заготовленных аватаров (PRESET_AVATAR) через opcode 16. /// firstName / lastName – текущие значения профиля (как в примерах сервера). Future setPresetAvatar({ required String firstName, required String lastName, required int photoId, }) async { try { await waitUntilOnline(); final payload = { "firstName": firstName, "lastName": lastName, "photoId": photoId, "avatarType": "PRESET_AVATAR", }; final int seq16 = _sendMessage(16, payload); _log('➡️ SEND: opcode=16 (PRESET_AVATAR), payload=$payload'); final resp16 = await messages.firstWhere( (msg) => msg['seq'] == seq16 && msg['opcode'] == 16, ); final Map? respPayload16 = resp16['payload'] as Map?; if (respPayload16 == null) { throw Exception('Пустой ответ сервера на установку пресет‑аватара'); } if (respPayload16.containsKey('error')) { final humanMessage = respPayload16['localizedMessage'] ?? respPayload16['message'] ?? respPayload16['title'] ?? respPayload16['error']; throw Exception(humanMessage.toString()); } final profileJson = respPayload16['profile']; if (profileJson is Map) { _lastChatsPayload ??= { 'chats': [], 'contacts': [], 'profile': null, 'presence': null, 'config': null, }; _lastChatsPayload!['profile'] = profileJson; return Profile.fromJson(profileJson); } } catch (e) { _log('❌ Ошибка при установке пресет‑аватара: $e'); } return null; } Future sendPhotoMessage( int chatId, { String? localPath, String? caption, int? cidOverride, int? senderId, }) async { try { XFile? image; if (localPath != null) { image = XFile(localPath); } else { final picker = ImagePicker(); image = await picker.pickImage(source: ImageSource.gallery); if (image == null) return; } await waitUntilOnline(); final int seq80 = _sendMessage(80, {"count": 1}); final resp80 = await messages.firstWhere((m) => m['seq'] == seq80); final String uploadUrl = resp80['payload']['url']; var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', image.path)); var streamed = await request.send(); var httpResp = await http.Response.fromStream(streamed); if (httpResp.statusCode != 200) { throw Exception( 'Ошибка загрузки фото: ${httpResp.statusCode} ${httpResp.body}', ); } final uploadJson = jsonDecode(httpResp.body) as Map; final Map photos = uploadJson['photos'] as Map; if (photos.isEmpty) throw Exception('Не получен токен фото'); final String photoToken = (photos.values.first as Map)['token']; final int cid = cidOverride ?? DateTime.now().millisecondsSinceEpoch; final payload = { "chatId": chatId, "message": { "text": caption?.trim() ?? "", "cid": cid, "elements": [], "attaches": [ {"_type": "PHOTO", "photoToken": photoToken}, ], }, "notify": true, }; clearChatsCache(); if (localPath != null) { _emitLocal({ 'ver': 11, 'cmd': 1, 'seq': -1, 'opcode': 128, 'payload': { 'chatId': chatId, 'message': { 'id': 'local_$cid', 'sender': senderId ?? 0, 'time': DateTime.now().millisecondsSinceEpoch, 'text': caption?.trim() ?? '', 'type': 'USER', 'cid': cid, 'attaches': [ {'_type': 'PHOTO', 'url': 'file://$localPath'}, ], }, }, }); } _sendMessage(64, payload); } catch (e) { print('Ошибка отправки фото-сообщения: $e'); } } Future sendPhotoMessages( int chatId, { required List localPaths, String? caption, int? senderId, }) async { if (localPaths.isEmpty) return; try { await waitUntilOnline(); final int cid = DateTime.now().millisecondsSinceEpoch; _emitLocal({ 'ver': 11, 'cmd': 1, 'seq': -1, 'opcode': 128, 'payload': { 'chatId': chatId, 'message': { 'id': 'local_$cid', 'sender': senderId ?? 0, 'time': DateTime.now().millisecondsSinceEpoch, 'text': caption?.trim() ?? '', 'type': 'USER', 'cid': cid, 'attaches': [ for (final p in localPaths) {'_type': 'PHOTO', 'url': 'file://$p'}, ], }, }, }); final List> photoTokens = []; for (final path in localPaths) { final int seq80 = _sendMessage(80, {"count": 1}); final resp80 = await messages.firstWhere((m) => m['seq'] == seq80); final String uploadUrl = resp80['payload']['url']; var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', path)); var streamed = await request.send(); var httpResp = await http.Response.fromStream(streamed); if (httpResp.statusCode != 200) { throw Exception( 'Ошибка загрузки фото: ${httpResp.statusCode} ${httpResp.body}', ); } final uploadJson = jsonDecode(httpResp.body) as Map; final Map photos = uploadJson['photos'] as Map; if (photos.isEmpty) throw Exception('Не получен токен фото'); final String photoToken = (photos.values.first as Map)['token']; photoTokens.add({"token": photoToken}); } final payload = { "chatId": chatId, "message": { "text": caption?.trim() ?? "", "cid": cid, "elements": [], "attaches": [ for (final t in photoTokens) {"_type": "PHOTO", "photoToken": t["token"]}, ], }, "notify": true, }; clearChatsCache(); _sendMessage(64, payload); } catch (e) { print('Ошибка отправки фото-сообщений: $e'); } } Future sendFileMessage( int chatId, { String? caption, int? senderId, }) async { try { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.any, ); if (result == null || result.files.single.path == null) { print("Выбор файла отменен"); return; } final String filePath = result.files.single.path!; final String fileName = result.files.single.name; final int fileSize = result.files.single.size; await waitUntilOnline(); // Показываем локальное сообщение с файлом сразу, как отправляется final int cid = DateTime.now().millisecondsSinceEpoch; _emitLocal({ 'ver': 11, 'cmd': 1, 'seq': -1, 'opcode': 128, 'payload': { 'chatId': chatId, 'message': { 'id': 'local_$cid', 'sender': senderId ?? 0, 'time': DateTime.now().millisecondsSinceEpoch, 'text': caption?.trim() ?? '', 'type': 'USER', 'cid': cid, 'attaches': [ { '_type': 'FILE', 'name': fileName, 'size': fileSize, 'url': 'file://$filePath', }, ], }, }, }); // Запрашиваем URL для загрузки файла final int seq87 = _sendMessage(87, {"count": 1}); final resp87 = await messages.firstWhere((m) => m['seq'] == seq87); if (resp87['payload'] == null || resp87['payload']['info'] == null || (resp87['payload']['info'] as List).isEmpty) { throw Exception('Неверный ответ на Opcode 87: отсутствует "info"'); } final uploadInfo = (resp87['payload']['info'] as List).first; final String uploadUrl = uploadInfo['url']; final int fileId = uploadInfo['fileId']; final String token = uploadInfo['token']; print('Получен fileId: $fileId, token: $token и URL: $uploadUrl'); // Начинаем heartbeat каждые 5 секунд Timer? heartbeatTimer; heartbeatTimer = Timer.periodic(const Duration(seconds: 5), (_) { _sendMessage(65, {"chatId": chatId, "type": "FILE"}); print('Heartbeat отправлен для загрузки файла'); }); try { // Загружаем файл var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', filePath)); var streamed = await request.send(); var httpResp = await http.Response.fromStream(streamed); if (httpResp.statusCode != 200) { throw Exception( 'Ошибка загрузки файла: ${httpResp.statusCode} ${httpResp.body}', ); } print('Файл успешно загружен на сервер. Ожидаем подтверждение...'); // Ждем уведомления о завершении загрузки (opcode 136) final uploadCompleteMsg = await messages .timeout(const Duration(seconds: 30)) .firstWhere( (msg) => msg['opcode'] == 136 && msg['payload']['fileId'] == fileId, ); print( 'Получено подтверждение загрузки файла: ${uploadCompleteMsg['payload']}', ); // Останавливаем heartbeat heartbeatTimer.cancel(); final payload = { "chatId": chatId, "message": { "text": caption?.trim() ?? "", "cid": cid, "elements": [], "attaches": [ {"_type": "FILE", "fileId": fileId}, ], }, "notify": true, }; clearChatsCache(); _sendMessage(64, payload); print('Сообщение о файле (Opcode 64) отправлено.'); } finally { // Гарантированно останавливаем heartbeat в случае ошибки heartbeatTimer.cancel(); } } catch (e) { print('Ошибка отправки файла: $e'); } } Future getVideoUrl(int videoId, int chatId, String messageId) async { await waitUntilOnline(); final payload = { "videoId": videoId, "chatId": chatId, "messageId": messageId, }; final int seq = _sendMessage(83, payload); print('Запрашиваем URL для videoId: $videoId (seq: $seq)'); try { final response = await messages .firstWhere((msg) => msg['seq'] == seq && msg['opcode'] == 83) .timeout(const Duration(seconds: 15)); if (response['cmd'] == 3) { throw Exception( 'Ошибка получения URL видео: ${response['payload']?['message']}', ); } final videoPayload = response['payload'] as Map?; if (videoPayload == null) { throw Exception('Получен пустой payload для видео'); } String? videoUrl = videoPayload['MP4_720'] as String? ?? videoPayload['MP4_480'] as String? ?? videoPayload['MP4_1080'] as String? ?? videoPayload['MP4_360'] as String?; if (videoUrl == null) { final mp4Key = videoPayload.keys.firstWhere( (k) => k.startsWith('MP4_'), orElse: () => '', ); if (mp4Key.isNotEmpty) { videoUrl = videoPayload[mp4Key] as String?; } } if (videoUrl != null) { print('URL для videoId: $videoId успешно получен.'); return videoUrl; } else { throw Exception('Не найден ни один MP4 URL в ответе'); } } on TimeoutException { print('Таймаут ожидания URL для videoId: $videoId'); throw Exception('Сервер не ответил на запрос видео вовремя'); } catch (e) { print('Ошибка в getVideoUrl: $e'); rethrow; } } }