diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 30f6d86..44cd24d 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -233,13 +233,22 @@ class MainActivity : FlutterActivity() { when (call.method) { "init" -> { val front = call.argument("front") ?: true - val rec = VideoNoteRecorder(applicationContext, flutterEngine.renderer) + val size = call.argument("size") ?: 480 + val fps = call.argument("fps") ?: 30 + val rec = VideoNoteRecorder( + applicationContext, + flutterEngine.renderer, + size, + fps, + ) noteRecorder?.dispose() noteRecorder = rec rec.init(front, result) } "start" -> noteRecorder?.start(result) ?: result.error("NOT_READY", "recorder not initialized", null) + "switch" -> noteRecorder?.switchCamera(result) + ?: result.error("NOT_READY", "recorder not initialized", null) "stop" -> noteRecorder?.stop(result) ?: result.error("NOT_READY", "recorder not initialized", null) "dispose" -> { diff --git a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt index 7704453..eeee6e8 100644 --- a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt +++ b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt @@ -23,6 +23,7 @@ import android.os.Build import android.os.Handler import android.os.HandlerThread import android.util.Log +import android.util.Range import android.util.Size import android.view.Surface import androidx.core.content.ContextCompat @@ -35,22 +36,32 @@ import java.nio.FloatBuffer // Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный // кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит -// одновременно в превью (Flutter Texture) и в MediaRecorder (480×480, H.264, +// одновременно в превью (Flutter Texture) и в MediaRecorder (квадрат, H.264, // framework MediaMuxer). Так делает официальный клиент через CameraX — выход // проходит серверный валидатор (media3-перекод его НЕ проходит). +// По умолчанию 480×480@30 как у официального клиента; размер и fps +// настраиваются из дев-меню. class VideoNoteRecorder( private val context: Context, private val textureRegistry: TextureRegistry, + requestedEdge: Int = 480, + requestedFps: Int = 30, ) { private val tag = "VideoNoteRecorder" - private val edge = 480 - private val bitrate = 1_024_000 - private val fps = 30 + private val edge = requestedEdge.coerceIn(240, 1080) + private val fps = requestedFps.coerceIn(24, 60) + // 1 Мбит/с — базовый битрейт официального клиента для 480×480@30; + // масштабируем по площади кадра и частоте. + private val bitrate = + (1_024_000L * edge * edge / (480L * 480L) * fps / 30L).toInt() private var cameraId = "" private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT private var sensorOrientation = 270 private var camSize = Size(1280, 720) + private var fpsRange: Range? = null + private var hasOis = false + private var hasEis = false private var cameraDevice: CameraDevice? = null private var session: CameraCaptureSession? = null @@ -95,23 +106,42 @@ class VideoNoteRecorder( CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP, ) camSize = pickCamSize(map) + fpsRange = pickFpsRange( + ch.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES), + ) + hasOis = ch.get( + CameraCharacteristics.LENS_INFO_AVAILABLE_OPTICAL_STABILIZATION, + )?.contains( + CameraCharacteristics.LENS_OPTICAL_STABILIZATION_MODE_ON, + ) == true + hasEis = ch.get( + CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES, + )?.contains( + CameraCharacteristics.CONTROL_VIDEO_STABILIZATION_MODE_ON, + ) == true return true } } return false } - // Поддерживаемый камерой размер вывода (для SurfaceTexture), близкий к 720p. + // Поддерживаемый камерой размер вывода (для SurfaceTexture): короткая + // сторона не меньше edge, целимся в ближайший 16:9 (720p для 480/720, + // 1080p для 1080). private fun pickCamSize(map: StreamConfigurationMap?): Size { + val targetShort = maxOf(720, edge) + val targetLong = targetShort * 16 / 9 + val fallback = Size(targetLong, targetShort) val sizes = map?.getOutputSizes(SurfaceTexture::class.java) - ?: return Size(1280, 720) - var best = sizes.firstOrNull() ?: Size(1280, 720) + ?: return fallback + var best = sizes.firstOrNull() ?: fallback var bestScore = Int.MAX_VALUE for (s in sizes) { val longSide = maxOf(s.width, s.height) val shortSide = minOf(s.width, s.height) if (shortSide < edge) continue - val score = kotlin.math.abs(longSide - 1280) + kotlin.math.abs(shortSide - 720) + val score = kotlin.math.abs(longSide - targetLong) + + kotlin.math.abs(shortSide - targetShort) if (score < bestScore) { bestScore = score best = s @@ -120,6 +150,18 @@ class VideoNoteRecorder( return best } + // Диапазон AE под запрошенный fps: предпочитаем фиксированный [fps, fps], + // иначе самый узкий диапазон, включающий fps; если 60 недоступно — + // максимально быстрый из имеющихся. + private fun pickFpsRange(ranges: Array>?): Range? { + if (ranges == null || ranges.isEmpty()) return null + val covering = ranges.filter { it.lower <= fps && fps <= it.upper } + if (covering.isNotEmpty()) { + return covering.minByOrNull { (it.upper - it.lower) * 1000 + (fps - it.lower) } + } + return ranges.maxByOrNull { it.upper * 1000 - (it.upper - it.lower) } + } + fun init(facingFront: Boolean, rawResult: MethodChannel.Result) { val result = OnceResult(rawResult) if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) @@ -213,7 +255,7 @@ class VideoNoteRecorder( recordWindow?.let { w -> w.makeCurrent() GLES20.glViewport(0, 0, edge, edge) - prog.draw(oesTexId, stMatrix, camSize, lensFacing, false) + prog.draw(oesTexId, stMatrix, camSize, lensFacing, true) w.setPresentationTime(System.nanoTime()) w.swap() } @@ -252,8 +294,28 @@ class VideoNoteRecorder( session = s val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) req.addTarget(camSurface) + fpsRange?.let { + req.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it) + } + // Оптическая стабилизация, если линза умеет; иначе электронная. + // Обе сразу включать нельзя — на многих устройствах конфликтуют. + if (hasOis) { + req.set( + CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE, + CaptureRequest.LENS_OPTICAL_STABILIZATION_MODE_ON, + ) + } else if (hasEis) { + req.set( + CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, + CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON, + ) + } s.setRepeatingRequest(req.build(), null, camHandler) - Log.i(tag, "preview session configured") + Log.i( + tag, + "preview session configured fpsRange=$fpsRange " + + "ois=$hasOis eis=$hasEis", + ) result.success(mapOf("textureId" to textureId, "size" to edge)) } } catch (e: Exception) { @@ -283,7 +345,7 @@ class VideoNoteRecorder( try { rec.setVideoEncodingProfileLevel( android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh, - android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3, + avcLevel(), ) } catch (e: Exception) { Log.w(tag, "profile level: ${e.message}") @@ -295,6 +357,56 @@ class VideoNoteRecorder( recorderSurface = rec.surface } + // Минимальный уровень AVC, вмещающий выбранные размер и fps + // (Level 3 — как у официального клиента для 480@30). + private fun avcLevel(): Int { + return when { + edge <= 480 && fps <= 30 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3 + edge <= 720 && fps <= 30 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel31 + edge <= 720 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel32 + fps <= 30 -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel4 + else -> + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel42 + } + } + + // Смена камеры на лету (в т.ч. во время записи): GL-конвейер и + // MediaRecorder не трогаем, пересоздаются только CameraDevice и сессия — + // кадры новой камеры продолжают приходить в тот же SurfaceTexture. + fun switchCamera(rawResult: MethodChannel.Result) { + val result = OnceResult(rawResult) + if (cameraDevice == null || !glReady) { + result.error("NOT_READY", "camera not initialized", null); return + } + val newFacing = if (lensFacing == CameraCharacteristics.LENS_FACING_FRONT) { + CameraCharacteristics.LENS_FACING_BACK + } else { + CameraCharacteristics.LENS_FACING_FRONT + } + try { session?.close() } catch (_: Exception) {} + session = null + try { cameraDevice?.close() } catch (_: Exception) {} + cameraDevice = null + if (!selectCamera(newFacing)) { + result.error("NO_CAMERA", "no camera for facing $newFacing", null) + return + } + val entry = flutterEntry + if (entry == null) { + result.error("NOT_READY", "texture released", null) + return + } + glHandler?.post { + camTexture?.setDefaultBufferSize(camSize.width, camSize.height) + } + Log.i(tag, "switching camera to $cameraId facing=$lensFacing cam=$camSize") + openCamera(result, entry.id()) + } + fun start(result: MethodChannel.Result) { if (cameraDevice == null || !glReady) { result.error("NOT_READY", "camera not initialized", null); return diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 3967ebe..65733cf 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -198,6 +198,12 @@ class FileUploader { final respBody = utf8.decode(result.body, allowMalformed: true); final hasError = respBody.contains('error_msg') || respBody.contains('error_code'); + if (result.status != 200 || hasError) { + logger.w( + 'uploadMediaFile rejected: status=${result.status} ' + 'body=${respBody.length > 500 ? respBody.substring(0, 500) : respBody}', + ); + } return result.status == 200 && !hasError; } catch (e) { logger.w('uploadMediaFile: $e'); @@ -292,6 +298,80 @@ class FileUploader { } } + /// Загрузка видео для истории. В отличие от чата (чанковый `uploadVideoPath` + /// с GET-handshake), story-эндпоинт `su.oneme.ru/uploadVideo` ждёт **один POST + /// на весь файл** (как у оригинального клиента) и возвращает медиа-токен в теле + /// ответа — `[{"token":"..."}]`. Именно этот токен идёт в `media.token` + /// STORIES_SEND, а не токен из ответа VIDEO_UPLOAD. + Future<({bool ok, String? token})> uploadVideoWithToken( + Uri uri, + File file, { + void Function(int sent, int total)? onProgress, + }) async { + final session = api.session; + if (session == null) return (ok: false, token: null); + try { + final result = await _consume( + session.uploadFilePath( + url: uri.toString(), + path: file.path, + filename: _syntheticFilename(), + contentType: 'application/octet-stream', + connection: 'close', + ), + onProgress: onProgress, + ); + if (result.error != null || result.status != 200) { + logger.w( + 'uploadVideoWithToken: status=${result.status} error=${result.error}', + ); + return (ok: false, token: null); + } + final token = _parseVideoToken( + utf8.decode(result.body, allowMalformed: true), + ); + return (ok: true, token: token); + } catch (e) { + logger.w('uploadVideoWithToken: $e'); + return (ok: false, token: null); + } + } + + String? _parseVideoToken(String body) { + try { + final json = jsonDecode(body); + // Сервер отвечает массивом: [{"token":"..."}] + if (json is List) { + for (final v in json) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } + } + } + if (json is Map) { + for (final key in const ['videos', 'video', 'photos']) { + final node = json[key]; + if (node is Map) { + for (final v in node.values) { + if (v is Map) { + final token = v['token']; + if (token is String && token.isNotEmpty) return token; + } + } + } + } + for (final key in const ['token', 'videoToken', 'photoToken']) { + final t = json[key]; + if (t is String && t.isNotEmpty) return t; + } + } + } catch (e) { + logger.w('parseVideoToken: $e'); + } + return null; + } + /// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог. Future<({int status, Uint8List body, String? error})> _consume( Stream stream, { diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index ab948df..05135f8 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1329,8 +1329,11 @@ class MessagesModule { ); } - Future requestPhotoUploadUrl() async { - final response = await _api.sendRequest(Opcode.photoUpload, {'count': 1}); + Future requestPhotoUploadUrl({int? type}) async { + final response = await _api.sendRequest(Opcode.photoUpload, { + 'type': ?type, + 'count': 1, + }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; @@ -1371,10 +1374,10 @@ class MessagesModule { ); } - Future requestVideoUploadUrl() async { + Future requestVideoUploadUrl({int type = 0}) async { final response = await _api.sendRequest(Opcode.videoUpload, { 'uploaderType': 0, - 'type': 0, + 'type': type, 'count': 1, }); if (!response.isOk) return null; diff --git a/lib/backend/modules/stories.dart b/lib/backend/modules/stories.dart index 9e21706..4955f68 100644 --- a/lib/backend/modules/stories.dart +++ b/lib/backend/modules/stories.dart @@ -322,13 +322,45 @@ class StoriesModule { } /// Публикация фото-истории. [photoToken] — токен уже загруженного фото. - /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, сек. + /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс. /// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI /// показал реальную причину, а не общее «не удалось». Future publishPhoto({ required String photoToken, int settings = 1, - int expiration = 86400, + int expiration = 86400000, + }) { + return _publishMedia( + media: {'_type': 'PHOTO', 'photoToken': photoToken}, + settings: settings, + expiration: expiration, + ); + } + + /// Публикация видео-истории. [videoToken] — токен уже загруженного видео + /// (`VideoUploadInfo.token`), [durationMs] — длительность ролика в мс. + Future publishVideo({ + required String videoToken, + int? durationMs, + int settings = 1, + int expiration = 86400000, + }) { + return _publishMedia( + media: { + '_type': 'VIDEO', + 'videoType': 2, + 'token': videoToken, + 'duration': ?durationMs, + }, + settings: settings, + expiration: expiration, + ); + } + + Future _publishMedia({ + required Map media, + required int settings, + required int expiration, }) async { if (_api.state != SessionState.online) { throw const PacketError('Нет соединения с сервером'); @@ -339,7 +371,7 @@ class StoriesModule { { 'cid': cid, 'settings': settings, - 'media': {'_type': 'PHOTO', 'photoToken': photoToken}, + 'media': media, 'expiration': expiration, }, ], diff --git a/lib/core/config/app_video_note_quality.dart b/lib/core/config/app_video_note_quality.dart new file mode 100644 index 0000000..b582607 --- /dev/null +++ b/lib/core/config/app_video_note_quality.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +import 'persisted_setting.dart'; + +class AppVideoNoteResolution { + static const prefKey = 'dev_video_note_resolution'; + static const int defaultValue = 480; + static const List presets = [480, 720, 1080]; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + sanitize: (value) => presets.contains(value) ? value : defaultValue, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); +} + +class AppVideoNoteRearCamera { + static const prefKey = 'dev_video_note_rear_camera'; + static const bool defaultValue = false; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} + +class AppVideoNoteFps { + static const prefKey = 'dev_video_note_fps'; + static const int defaultValue = 30; + static const List presets = [30, 60]; + + static final _setting = PersistedSetting( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getInt(key), + write: (prefs, key, value) async { + await prefs.setInt(key, value); + }, + sanitize: (value) => presets.contains(value) ? value : defaultValue, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(int value) => _setting.save(value); +} diff --git a/lib/core/media/native_video_note_recorder.dart b/lib/core/media/native_video_note_recorder.dart index 6b80f4b..33f5b8d 100644 --- a/lib/core/media/native_video_note_recorder.dart +++ b/lib/core/media/native_video_note_recorder.dart @@ -5,20 +5,23 @@ import 'package:flutter/services.dart'; import '../utils/logger.dart'; /// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет -/// квадрат 480×480 сразу при съёмке — как официальный клиент. Превью отдаётся -/// через Flutter [Texture] по [textureId]. media3-перекод не используется -/// (серверный валидатор принимает только нативно записанный MP4). +/// квадрат сразу при съёмке — как официальный клиент (по умолчанию 480×480@30, +/// размер и fps настраиваются в дев-меню). Превью отдаётся через Flutter +/// [Texture] по [textureId]. media3-перекод не используется (серверный +/// валидатор принимает только нативно записанный MP4). class NativeVideoNoteRecorder { static const _channel = MethodChannel('ru.komet.app/video_note'); int? textureId; bool get isAvailable => Platform.isAndroid; - Future init({bool front = true}) async { + Future init({bool front = true, int size = 480, int fps = 30}) async { if (!isAvailable) return false; try { final res = await _channel.invokeMapMethod('init', { 'front': front, + 'size': size, + 'fps': fps, }); textureId = res?['textureId'] as int?; return textureId != null; @@ -28,6 +31,17 @@ class NativeVideoNoteRecorder { } } + Future switchCamera() async { + if (!isAvailable) return false; + try { + await _channel.invokeMethod('switch'); + return true; + } catch (e) { + logger.w('NativeVideoNoteRecorder.switchCamera: $e'); + return false; + } + } + Future start() async { if (!isAvailable) return false; try { diff --git a/lib/frontend/debug/feature_toggles_section.dart b/lib/frontend/debug/feature_toggles_section.dart index 55cd2cc..2ecf27c 100644 --- a/lib/frontend/debug/feature_toggles_section.dart +++ b/lib/frontend/debug/feature_toggles_section.dart @@ -10,9 +10,11 @@ import '../../core/config/app_pranks.dart'; import '../../core/config/app_show_extra_info.dart'; import '../../core/config/app_stories.dart'; import '../../core/config/app_swipe_back_desktop.dart'; +import '../../core/config/app_video_note_quality.dart'; import '../../core/contacts/device_contacts_service.dart'; import '../screens/digital_id/digital_id_web_screen.dart'; import '../widgets/custom_notification.dart'; +import '../widgets/sheet_helpers.dart'; import 'debug_toggle_tile.dart'; class DebugFeatureTogglesSection extends StatelessWidget { @@ -35,6 +37,86 @@ class DebugFeatureTogglesSection extends StatelessWidget { ContactsModule.revision.value++; } + void _pickVideoNoteQuality(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: kSheetShape, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Качество записи кружков', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Разрешение', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + ), + for (final preset in AppVideoNoteResolution.presets) + ValueListenableBuilder( + valueListenable: AppVideoNoteResolution.current, + builder: (context, value, _) => ListTile( + title: Text( + '$preset×$preset', + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + trailing: value == preset + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () => AppVideoNoteResolution.save(preset), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Частота кадров', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + ), + for (final preset in AppVideoNoteFps.presets) + ValueListenableBuilder( + valueListenable: AppVideoNoteFps.current, + builder: (context, value, _) => ListTile( + title: Text( + '$preset fps', + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + trailing: value == preset + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () => AppVideoNoteFps.save(preset), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -142,6 +224,76 @@ class DebugFeatureTogglesSection extends StatelessWidget { onChanged: AppStories.save, ), ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _pickVideoNoteQuality(context), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.video_camera_front, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Качество записи кружков', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + ValueListenableBuilder( + valueListenable: AppVideoNoteResolution.current, + builder: (context, res, _) => + ValueListenableBuilder( + valueListenable: AppVideoNoteFps.current, + builder: (context, fps, _) => Text( + '$res×$res • $fps fps (только Android)', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: DebugToggleTile( + icon: Symbols.flip_camera_android, + title: 'Кружки с задней камеры', + subtitle: (v) => v + ? 'Запись кружка начинается с задней камеры' + : 'Запись кружка начинается с фронтальной камеры', + valueListenable: AppVideoNoteRearCamera.current, + onChanged: AppVideoNoteRearCamera.save, + ), + ), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: DebugToggleTile( diff --git a/lib/frontend/screens/chats/chat/video_note_controller.dart b/lib/frontend/screens/chats/chat/video_note_controller.dart index cbe3842..af8e06c 100644 --- a/lib/frontend/screens/chats/chat/video_note_controller.dart +++ b/lib/frontend/screens/chats/chat/video_note_controller.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../../../../core/config/app_video_note_quality.dart'; import '../../../../core/media/native_video_note_recorder.dart'; import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; @@ -35,6 +36,10 @@ class VideoNoteController { Timer? _timer; bool _cancelled = false; bool _stopRequested = false; + bool? _frontOverride; + bool _switchingCamera = false; + + bool get _front => _frontOverride ?? !AppVideoNoteRearCamera.current.value; OverlayEntry? _overlay; ValueListenable get videoNoteMode => _videoNoteMode; @@ -59,7 +64,11 @@ class VideoNoteController { return; } try { - final ok = await _rec.init(); + final ok = await _rec.init( + front: _front, + size: AppVideoNoteResolution.current.value, + fps: AppVideoNoteFps.current.value, + ); if (!ok) { if (isMounted()) { showCustomNotification(contextOf(), 'Камера недоступна'); @@ -124,6 +133,20 @@ class VideoNoteController { } } + Future flipCamera() async { + if (!_camReady.value || _switchingCamera) return; + _switchingCamera = true; + try { + final ok = await _rec.switchCamera(); + if (ok) { + _frontOverride = !_front; + Haptics.tap(); + } + } finally { + _switchingCamera = false; + } + } + void handleDrag(Offset offsetFromOrigin) { if (!_isRecording.value) return; final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold) @@ -164,7 +187,7 @@ class VideoNoteController { return; } - // Файл уже квадратный 480×480 (нативная запись) — шлём как есть. + // Файл уже квадратный (нативная запись) — шлём как есть. await onRecorded(File(path), elapsed); } @@ -174,14 +197,15 @@ class VideoNoteController { builder: (context) { final texId = _textureId.value; return Positioned.fill( - child: IgnorePointer( - child: Container( - color: Colors.black.withValues(alpha: 0.55), - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipOval( + child: Container( + color: Colors.black.withValues(alpha: 0.55), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: flipCamera, + child: ClipOval( child: SizedBox( width: 260, height: 260, @@ -190,31 +214,36 @@ class VideoNoteController { : Container(color: Colors.black), ), ), - const SizedBox(height: 20), - ValueListenableBuilder( - valueListenable: _elapsedMs, - builder: (context, ms, _) => Text( - formatElapsed(ms), - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontFeatures: [ui.FontFeature.tabularFigures()], - ), + ), + const SizedBox(height: 20), + ValueListenableBuilder( + valueListenable: _elapsedMs, + builder: (context, ms, _) => Text( + formatElapsed(ms), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontFeatures: [ui.FontFeature.tabularFigures()], ), ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: _cancelDrag, - builder: (context, drag, _) => Opacity( - opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0), - child: const Text( - '‹ влево — отмена', - style: TextStyle(color: Colors.white70, fontSize: 13), - ), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: _cancelDrag, + builder: (context, drag, _) => Opacity( + opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0), + child: const Text( + '‹ влево — отмена', + style: TextStyle(color: Colors.white70, fontSize: 13), ), ), - ], - ), + ), + const SizedBox(height: 4), + const Text( + 'тап по кружку — сменить камеру', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), + ], ), ), ); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index e398957..ba1e3a3 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -2325,27 +2325,29 @@ class _ChatListScreenState extends State onSend: (photos, caption) async { if (photos.isEmpty) return; final picked = photos.first; - if (picked.item.isVideo) { - if (mounted) { - showCustomNotification( - context, - 'Видео в историях пока не поддерживается', - ); - } - return; - } + final isVideo = picked.item.isVideo; final file = picked.editedFile ?? picked.item.localFile ?? await picked.item.originFile(); if (file == null) { if (mounted) { - showCustomNotification(context, 'Не удалось открыть фото'); + showCustomNotification( + context, + isVideo ? 'Не удалось открыть видео' : 'Не удалось открыть фото', + ); } return; } if (!mounted) return; - pushSwipeable(context, (_) => StoryComposerScreen(file: file)); + pushSwipeable( + context, + (_) => StoryComposerScreen( + file: file, + isVideo: isVideo, + durationMs: picked.item.duration?.inMilliseconds, + ), + ); }, ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 39a3130..bb3b873 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5423,9 +5423,18 @@ class _ChatScreenState extends State unawaited(_persistOutgoing(real, removeId: tempId)); } _disposePhotoProgress(tempId); - } catch (_) { + } catch (e) { + logger.w('sendVideoNote failed: $e'); if (mounted) { _failPhotoMessage(tempId); + final reason = e is PacketError + ? '${e.errorKey ?? ''} ${e.message}'.trim() + : e is Exception && e.toString().contains('send_failed') + ? 'сервер не обработал видео' + : e is Exception && e.toString().contains('upload_failed') + ? 'загрузка отклонена' + : e.toString(); + showCustomNotification(context, 'Кружок не отправлен: $reason'); } else { _disposePhotoProgress(tempId); } diff --git a/lib/frontend/screens/stories/story_composer_screen.dart b/lib/frontend/screens/stories/story_composer_screen.dart index f6d26bc..916ebc6 100644 --- a/lib/frontend/screens/stories/story_composer_screen.dart +++ b/lib/frontend/screens/stories/story_composer_screen.dart @@ -1,19 +1,28 @@ import 'dart:io'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; import '../../../core/utils/haptics.dart'; import '../../../main.dart' show fileUploader, messagesModule, storiesModule; import '../../widgets/custom_notification.dart'; import '../../widgets/primary_loading_button.dart'; -const int _storyExpiration = 86400; +const int _storyExpiration = 86400000; class StoryComposerScreen extends StatefulWidget { final File file; + final bool isVideo; + final int? durationMs; - const StoryComposerScreen({super.key, required this.file}); + const StoryComposerScreen({ + super.key, + required this.file, + this.isVideo = false, + this.durationMs, + }); @override State createState() => _StoryComposerScreenState(); @@ -22,10 +31,29 @@ class StoryComposerScreen extends StatefulWidget { class _StoryComposerScreenState extends State { final ValueNotifier _publishing = ValueNotifier(false); int _audience = 1; // 1 = все, 2 = контакты + VideoPlayerController? _video; + + @override + void initState() { + super.initState(); + if (widget.isVideo) _initVideo(); + } + + Future _initVideo() async { + final controller = VideoPlayerController.file(widget.file); + _video = controller; + try { + await controller.initialize(); + await controller.setLooping(true); + await controller.play(); + if (mounted) setState(() {}); + } catch (_) {} + } @override void dispose() { _publishing.dispose(); + _video?.dispose(); super.dispose(); } @@ -33,37 +61,80 @@ class _StoryComposerScreenState extends State { if (_publishing.value) return; _publishing.value = true; try { - final url = await messagesModule.requestPhotoUploadUrl(); - if (url == null || url.isEmpty) { - _fail('Не удалось получить адрес загрузки'); - return; + if (widget.isVideo) { + await _publishVideo(); + } else { + await _publishPhoto(); } - final segments = widget.file.uri.pathSegments; - final filename = segments.isNotEmpty ? segments.last : 'story.jpg'; - final token = await fileUploader.uploadPhoto( - Uri.parse(url), - widget.file, - filename: filename.isEmpty ? 'story.jpg' : filename, - ); - if (token == null || token.isEmpty) { - _fail('Не удалось загрузить фото'); - return; - } - await storiesModule.publishPhoto( - photoToken: token, - settings: _audience, - expiration: _storyExpiration, - ); - if (!mounted) return; - Haptics.success(); - Navigator.of(context).pop(); - showCustomNotification(context, 'История опубликована'); - storiesModule.loadFeed(); } catch (e) { _fail(e.toString()); } } + Future _publishPhoto() async { + final url = await messagesModule.requestPhotoUploadUrl(type: 1); + if (url == null || url.isEmpty) { + _fail('Не удалось получить адрес загрузки'); + return; + } + final segments = widget.file.uri.pathSegments; + final filename = segments.isNotEmpty ? segments.last : 'story.jpg'; + final token = await fileUploader.uploadPhoto( + Uri.parse(url), + widget.file, + filename: filename.isEmpty ? 'story.jpg' : filename, + ); + if (token == null || token.isEmpty) { + _fail('Не удалось загрузить фото'); + return; + } + await storiesModule.publishPhoto( + photoToken: token, + settings: _audience, + expiration: _storyExpiration, + ); + _onPublished(); + } + + Future _publishVideo() async { + final info = await messagesModule.requestVideoUploadUrl(type: 3); + if (info == null || info.url.isEmpty) { + _fail('Не удалось получить адрес загрузки'); + return; + } + final upload = await fileUploader.uploadVideoWithToken( + Uri.parse(info.url), + widget.file, + ); + if (!upload.ok) { + _fail('Не удалось загрузить видео'); + return; + } + final uploadedToken = upload.token; + final token = (uploadedToken != null && uploadedToken.isNotEmpty) + ? uploadedToken + : info.token; + if (token.isEmpty) { + _fail('Не удалось загрузить видео'); + return; + } + await storiesModule.publishVideo( + videoToken: token, + durationMs: widget.durationMs, + settings: _audience, + expiration: _storyExpiration, + ); + _onPublished(); + } + + void _onPublished() { + if (!mounted) return; + Haptics.success(); + Navigator.of(context).pop(); + showCustomNotification(context, 'История опубликована'); + storiesModule.loadFeed(); + } + void _fail(String message) { if (!mounted) { _publishing.value = false; @@ -74,6 +145,48 @@ class _StoryComposerScreenState extends State { showCustomNotification(context, message); } + Widget _buildPreview() { + final Widget foreground; + final Widget backdrop; + if (widget.isVideo) { + final c = _video; + if (c == null || !c.value.isInitialized) { + return const CircularProgressIndicator(color: Colors.white); + } + final size = c.value.size; + foreground = Center( + child: AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ), + ); + backdrop = FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: size.width, + height: size.height, + child: VideoPlayer(c), + ), + ); + } else { + foreground = Image.file(widget.file, fit: BoxFit.contain); + backdrop = Image.file(widget.file, fit: BoxFit.cover); + } + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: backdrop, + ), + ), + const Positioned.fill(child: ColoredBox(color: Colors.black26)), + foreground, + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -81,9 +194,7 @@ class _StoryComposerScreenState extends State { body: Stack( fit: StackFit.expand, children: [ - Center( - child: Image.file(widget.file, fit: BoxFit.contain), - ), + Center(child: _buildPreview()), Positioned( top: 0, left: 0, diff --git a/lib/main.dart b/lib/main.dart index 944a723..9c4b46e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -38,6 +38,7 @@ import 'core/config/app_phonebook_names.dart'; import 'core/contacts/device_contacts_service.dart'; import 'core/config/app_link_preview.dart'; import 'core/config/app_media_cache.dart'; +import 'core/config/app_video_note_quality.dart'; import 'core/config/app_pill_gradient.dart'; import 'core/config/app_visual_style.dart'; import 'core/config/app_chat_chrome.dart'; @@ -219,6 +220,9 @@ void main(List args) async { final phonebookNamesFuture = AppPhonebookNames.load(); final linkPreviewFuture = AppLinkPreview.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); + final videoNoteResolutionFuture = AppVideoNoteResolution.load(); + final videoNoteFpsFuture = AppVideoNoteFps.load(); + final videoNoteRearCameraFuture = AppVideoNoteRearCamera.load(); final digitalIdNativeFuture = AppDigitalIdNative.load(); final showExtraInfoFuture = AppShowExtraInfo.load(); final trafficCaptureFuture = TrafficMonitor.instance.load(); @@ -275,6 +279,9 @@ void main(List args) async { phonebookNamesFuture, linkPreviewFuture, cacheLimitFuture, + videoNoteResolutionFuture, + videoNoteFpsFuture, + videoNoteRearCameraFuture, digitalIdNativeFuture, showExtraInfoFuture, ]);