feat: поддержка медиа-вложений — опросы, видео, файлы и просмотр фото

Опросы (новый тип сообщения):
- модель Poll/PollAnswer и PollAttachment (_type: POLL)
- PollsModule: загрузка через opcode 306 (GET_POLL_UPDATES) с кэшем
- рендер опроса в баблах (вопрос, варианты, прогресс-бары, голоса)

Видео:
- getVideoUrl через opcode 83 (VIDEO_PLAY): выбор MP4_*/HLS из ответа
- полноэкранный плеер (video_player) с play/pause и перемоткой
- тап по видео в чате запускает воспроизведение

Файлы:
- getFileUrl через opcode 88 (FILE_DOWNLOAD) с корректным форматом
  {messageId, chatId, fileId} → url (раньше слался неверный {url, token})
- скачивание во временную папку и открытие системным приложением
  (path_provider, open_filex)

Фото:
- полноэкранный просмотрщик с зумом (был TODO-заглушкой)
- фикс «сжатости»: декодирование с учётом devicePixelRatio вместо ×2
This commit is contained in:
klockky
2026-06-02 20:06:46 +00:00
parent 1bcdb180af
commit ef05c9eaaa
13 changed files with 787 additions and 33 deletions
+50 -21
View File
@@ -568,6 +568,43 @@ class MessagesModule {
}
}
/// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`,
/// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`.
/// Возвращает лучший доступный progressive-MP4 (или HLS как запасной).
Future<String?> getVideoUrl({
required String messageId,
required int chatId,
required String token,
required int videoId,
}) async {
try {
final response = await _api.sendRequest(Opcode.videoPlay, {
'messageId': int.tryParse(messageId) ?? 0,
'chatId': chatId,
'token': token,
'videoId': videoId,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240'];
for (final key in mp4Keys) {
final url = data[key];
if (url is String && url.isNotEmpty) return url;
}
final hls = data['HLS'];
if (hls is String && hls.isNotEmpty) return hls;
final external = data['EXTERNAL'];
if (external is String && external.isNotEmpty) return external;
return null;
} catch (_) {
return null;
}
}
Future<Uint8List?> downloadVideo(String baseUrl, String videoToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
@@ -588,23 +625,6 @@ class MessagesModule {
}
}
Future<String?> getVideoUrl(String baseUrl, String videoToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
'url': baseUrl,
'token': videoToken,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
return data['content'] as String?;
} catch (e) {
return null;
}
}
Future<Uint8List?> downloadFile(String baseUrl, String fileToken) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
@@ -625,18 +645,27 @@ class MessagesModule {
}
}
Future<String?> getFileUrl(String baseUrl, String fileToken) async {
/// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88).
///
/// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`,
/// ответ `{url: "https://fd.oneme.ru/getfile?..."}`.
Future<String?> getFileUrl({
required String messageId,
required int chatId,
required int fileId,
}) async {
try {
final response = await _api.sendRequest(Opcode.fileDownload, {
'url': baseUrl,
'token': fileToken,
'messageId': int.tryParse(messageId) ?? 0,
'chatId': chatId,
'fileId': fileId,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
return data['content'] as String?;
return data['url'] as String?;
} catch (e) {
return null;
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import '../api.dart';
import '../../core/protocol/opcode_map.dart';
import '../../models/poll.dart';
class PollsModule extends ChangeNotifier {
final Api _api;
PollsModule(this._api);
final Map<int, Poll> _cache = {};
final Set<int> _inFlight = {};
Poll? get(int pollId) => _cache[pollId];
Future<void> fetch(
int chatId,
String messageId,
int pollId, {
bool force = false,
}) async {
if (pollId == 0) return;
if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) {
return;
}
_inFlight.add(pollId);
try {
final mid = int.tryParse(messageId) ?? 0;
final response = await _api.sendRequest(Opcode.getPollUpdates, {
'chatId': chatId,
'polls': [
{'messageId': mid, 'pollId': pollId},
],
});
if (!response.isOk) return;
final data = response.payload;
if (data is! Map) return;
final polls = data['polls'];
if (polls is! List) return;
var changed = false;
for (final p in polls) {
if (p is Map) {
final poll = Poll.fromServerMap(p);
if (poll.pollId != 0) {
_cache[poll.pollId] = poll;
changed = true;
}
}
}
if (changed) notifyListeners();
} catch (_) {
// тихо игнорируем — опрос просто не отобразится
} finally {
_inFlight.remove(pollId);
}
}
}