feat(stories): публикация видео-историй + фикс TTL

This commit is contained in:
klockky
2026-07-24 15:31:50 +03:00
parent f7265b4fe8
commit b76da00f56
13 changed files with 723 additions and 96 deletions
@@ -233,13 +233,22 @@ class MainActivity : FlutterActivity() {
when (call.method) { when (call.method) {
"init" -> { "init" -> {
val front = call.argument<Boolean>("front") ?: true val front = call.argument<Boolean>("front") ?: true
val rec = VideoNoteRecorder(applicationContext, flutterEngine.renderer) val size = call.argument<Int>("size") ?: 480
val fps = call.argument<Int>("fps") ?: 30
val rec = VideoNoteRecorder(
applicationContext,
flutterEngine.renderer,
size,
fps,
)
noteRecorder?.dispose() noteRecorder?.dispose()
noteRecorder = rec noteRecorder = rec
rec.init(front, result) rec.init(front, result)
} }
"start" -> noteRecorder?.start(result) "start" -> noteRecorder?.start(result)
?: result.error("NOT_READY", "recorder not initialized", null) ?: result.error("NOT_READY", "recorder not initialized", null)
"switch" -> noteRecorder?.switchCamera(result)
?: result.error("NOT_READY", "recorder not initialized", null)
"stop" -> noteRecorder?.stop(result) "stop" -> noteRecorder?.stop(result)
?: result.error("NOT_READY", "recorder not initialized", null) ?: result.error("NOT_READY", "recorder not initialized", null)
"dispose" -> { "dispose" -> {
@@ -23,6 +23,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import android.util.Log import android.util.Log
import android.util.Range
import android.util.Size import android.util.Size
import android.view.Surface import android.view.Surface
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -35,22 +36,32 @@ import java.nio.FloatBuffer
// Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный // Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный
// кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит // кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит
// одновременно в превью (Flutter Texture) и в MediaRecorder (480×480, H.264, // одновременно в превью (Flutter Texture) и в MediaRecorder (квадрат, H.264,
// framework MediaMuxer). Так делает официальный клиент через CameraX — выход // framework MediaMuxer). Так делает официальный клиент через CameraX — выход
// проходит серверный валидатор (media3-перекод его НЕ проходит). // проходит серверный валидатор (media3-перекод его НЕ проходит).
// По умолчанию 480×480@30 как у официального клиента; размер и fps
// настраиваются из дев-меню.
class VideoNoteRecorder( class VideoNoteRecorder(
private val context: Context, private val context: Context,
private val textureRegistry: TextureRegistry, private val textureRegistry: TextureRegistry,
requestedEdge: Int = 480,
requestedFps: Int = 30,
) { ) {
private val tag = "VideoNoteRecorder" private val tag = "VideoNoteRecorder"
private val edge = 480 private val edge = requestedEdge.coerceIn(240, 1080)
private val bitrate = 1_024_000 private val fps = requestedFps.coerceIn(24, 60)
private val fps = 30 // 1 Мбит/с — базовый битрейт официального клиента для 480×480@30;
// масштабируем по площади кадра и частоте.
private val bitrate =
(1_024_000L * edge * edge / (480L * 480L) * fps / 30L).toInt()
private var cameraId = "" private var cameraId = ""
private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT
private var sensorOrientation = 270 private var sensorOrientation = 270
private var camSize = Size(1280, 720) private var camSize = Size(1280, 720)
private var fpsRange: Range<Int>? = null
private var hasOis = false
private var hasEis = false
private var cameraDevice: CameraDevice? = null private var cameraDevice: CameraDevice? = null
private var session: CameraCaptureSession? = null private var session: CameraCaptureSession? = null
@@ -95,23 +106,42 @@ class VideoNoteRecorder(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP, CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP,
) )
camSize = pickCamSize(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 true
} }
} }
return false return false
} }
// Поддерживаемый камерой размер вывода (для SurfaceTexture), близкий к 720p. // Поддерживаемый камерой размер вывода (для SurfaceTexture): короткая
// сторона не меньше edge, целимся в ближайший 16:9 (720p для 480/720,
// 1080p для 1080).
private fun pickCamSize(map: StreamConfigurationMap?): Size { 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) val sizes = map?.getOutputSizes(SurfaceTexture::class.java)
?: return Size(1280, 720) ?: return fallback
var best = sizes.firstOrNull() ?: Size(1280, 720) var best = sizes.firstOrNull() ?: fallback
var bestScore = Int.MAX_VALUE var bestScore = Int.MAX_VALUE
for (s in sizes) { for (s in sizes) {
val longSide = maxOf(s.width, s.height) val longSide = maxOf(s.width, s.height)
val shortSide = minOf(s.width, s.height) val shortSide = minOf(s.width, s.height)
if (shortSide < edge) continue 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) { if (score < bestScore) {
bestScore = score bestScore = score
best = s best = s
@@ -120,6 +150,18 @@ class VideoNoteRecorder(
return best return best
} }
// Диапазон AE под запрошенный fps: предпочитаем фиксированный [fps, fps],
// иначе самый узкий диапазон, включающий fps; если 60 недоступно —
// максимально быстрый из имеющихся.
private fun pickFpsRange(ranges: Array<Range<Int>>?): Range<Int>? {
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) { fun init(facingFront: Boolean, rawResult: MethodChannel.Result) {
val result = OnceResult(rawResult) val result = OnceResult(rawResult)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
@@ -213,7 +255,7 @@ class VideoNoteRecorder(
recordWindow?.let { w -> recordWindow?.let { w ->
w.makeCurrent() w.makeCurrent()
GLES20.glViewport(0, 0, edge, edge) 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.setPresentationTime(System.nanoTime())
w.swap() w.swap()
} }
@@ -252,8 +294,28 @@ class VideoNoteRecorder(
session = s session = s
val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
req.addTarget(camSurface) 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) 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)) result.success(mapOf("textureId" to textureId, "size" to edge))
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -283,7 +345,7 @@ class VideoNoteRecorder(
try { try {
rec.setVideoEncodingProfileLevel( rec.setVideoEncodingProfileLevel(
android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh, android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh,
android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3, avcLevel(),
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.w(tag, "profile level: ${e.message}") Log.w(tag, "profile level: ${e.message}")
@@ -295,6 +357,56 @@ class VideoNoteRecorder(
recorderSurface = rec.surface 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) { fun start(result: MethodChannel.Result) {
if (cameraDevice == null || !glReady) { if (cameraDevice == null || !glReady) {
result.error("NOT_READY", "camera not initialized", null); return result.error("NOT_READY", "camera not initialized", null); return
+80
View File
@@ -198,6 +198,12 @@ class FileUploader {
final respBody = utf8.decode(result.body, allowMalformed: true); final respBody = utf8.decode(result.body, allowMalformed: true);
final hasError = final hasError =
respBody.contains('error_msg') || respBody.contains('error_code'); 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; return result.status == 200 && !hasError;
} catch (e) { } catch (e) {
logger.w('uploadMediaFile: $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( Future<({int status, Uint8List body, String? error})> _consume(
Stream<kb.UploadEvent> stream, { Stream<kb.UploadEvent> stream, {
+7 -4
View File
@@ -1329,8 +1329,11 @@ class MessagesModule {
); );
} }
Future<String?> requestPhotoUploadUrl() async { Future<String?> requestPhotoUploadUrl({int? type}) async {
final response = await _api.sendRequest(Opcode.photoUpload, {'count': 1}); final response = await _api.sendRequest(Opcode.photoUpload, {
'type': ?type,
'count': 1,
});
if (!response.isOk) return null; if (!response.isOk) return null;
final data = response.payload; final data = response.payload;
if (data is! Map) return null; if (data is! Map) return null;
@@ -1371,10 +1374,10 @@ class MessagesModule {
); );
} }
Future<VideoUploadInfo?> requestVideoUploadUrl() async { Future<VideoUploadInfo?> requestVideoUploadUrl({int type = 0}) async {
final response = await _api.sendRequest(Opcode.videoUpload, { final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 0, 'uploaderType': 0,
'type': 0, 'type': type,
'count': 1, 'count': 1,
}); });
if (!response.isOk) return null; if (!response.isOk) return null;
+35 -3
View File
@@ -322,13 +322,45 @@ class StoriesModule {
} }
/// Публикация фото-истории. [photoToken] — токен уже загруженного фото. /// Публикация фото-истории. [photoToken] — токен уже загруженного фото.
/// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, сек. /// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс.
/// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI /// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI
/// показал реальную причину, а не общее «не удалось». /// показал реальную причину, а не общее «не удалось».
Future<void> publishPhoto({ Future<void> publishPhoto({
required String photoToken, required String photoToken,
int settings = 1, 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<void> 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<void> _publishMedia({
required Map<String, dynamic> media,
required int settings,
required int expiration,
}) async { }) async {
if (_api.state != SessionState.online) { if (_api.state != SessionState.online) {
throw const PacketError('Нет соединения с сервером'); throw const PacketError('Нет соединения с сервером');
@@ -339,7 +371,7 @@ class StoriesModule {
{ {
'cid': cid, 'cid': cid,
'settings': settings, 'settings': settings,
'media': {'_type': 'PHOTO', 'photoToken': photoToken}, 'media': media,
'expiration': expiration, 'expiration': expiration,
}, },
], ],
@@ -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<int> presets = [480, 720, 1080];
static final _setting = PersistedSetting<int>(
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<int> get current => _setting.current;
static Future<int> load() => _setting.load();
static Future<void> 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<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
class AppVideoNoteFps {
static const prefKey = 'dev_video_note_fps';
static const int defaultValue = 30;
static const List<int> presets = [30, 60];
static final _setting = PersistedSetting<int>(
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<int> get current => _setting.current;
static Future<int> load() => _setting.load();
static Future<void> save(int value) => _setting.save(value);
}
+18 -4
View File
@@ -5,20 +5,23 @@ import 'package:flutter/services.dart';
import '../utils/logger.dart'; import '../utils/logger.dart';
/// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет /// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет
/// квадрат 480×480 сразу при съёмке — как официальный клиент. Превью отдаётся /// квадрат сразу при съёмке — как официальный клиент (по умолчанию 480×480@30,
/// через Flutter [Texture] по [textureId]. media3-перекод не используется /// размер и fps настраиваются в дев-меню). Превью отдаётся через Flutter
/// (серверный валидатор принимает только нативно записанный MP4). /// [Texture] по [textureId]. media3-перекод не используется (серверный
/// валидатор принимает только нативно записанный MP4).
class NativeVideoNoteRecorder { class NativeVideoNoteRecorder {
static const _channel = MethodChannel('ru.komet.app/video_note'); static const _channel = MethodChannel('ru.komet.app/video_note');
int? textureId; int? textureId;
bool get isAvailable => Platform.isAndroid; bool get isAvailable => Platform.isAndroid;
Future<bool> init({bool front = true}) async { Future<bool> init({bool front = true, int size = 480, int fps = 30}) async {
if (!isAvailable) return false; if (!isAvailable) return false;
try { try {
final res = await _channel.invokeMapMethod<String, dynamic>('init', { final res = await _channel.invokeMapMethod<String, dynamic>('init', {
'front': front, 'front': front,
'size': size,
'fps': fps,
}); });
textureId = res?['textureId'] as int?; textureId = res?['textureId'] as int?;
return textureId != null; return textureId != null;
@@ -28,6 +31,17 @@ class NativeVideoNoteRecorder {
} }
} }
Future<bool> switchCamera() async {
if (!isAvailable) return false;
try {
await _channel.invokeMethod('switch');
return true;
} catch (e) {
logger.w('NativeVideoNoteRecorder.switchCamera: $e');
return false;
}
}
Future<bool> start() async { Future<bool> start() async {
if (!isAvailable) return false; if (!isAvailable) return false;
try { try {
@@ -10,9 +10,11 @@ import '../../core/config/app_pranks.dart';
import '../../core/config/app_show_extra_info.dart'; import '../../core/config/app_show_extra_info.dart';
import '../../core/config/app_stories.dart'; import '../../core/config/app_stories.dart';
import '../../core/config/app_swipe_back_desktop.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 '../../core/contacts/device_contacts_service.dart';
import '../screens/digital_id/digital_id_web_screen.dart'; import '../screens/digital_id/digital_id_web_screen.dart';
import '../widgets/custom_notification.dart'; import '../widgets/custom_notification.dart';
import '../widgets/sheet_helpers.dart';
import 'debug_toggle_tile.dart'; import 'debug_toggle_tile.dart';
class DebugFeatureTogglesSection extends StatelessWidget { class DebugFeatureTogglesSection extends StatelessWidget {
@@ -35,6 +37,86 @@ class DebugFeatureTogglesSection extends StatelessWidget {
ContactsModule.revision.value++; ContactsModule.revision.value++;
} }
void _pickVideoNoteQuality(BuildContext context) {
final cs = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
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<int>(
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<int>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
@@ -142,6 +224,76 @@ class DebugFeatureTogglesSection extends StatelessWidget {
onChanged: AppStories.save, 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<int>(
valueListenable: AppVideoNoteResolution.current,
builder: (context, res, _) =>
ValueListenableBuilder<int>(
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(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: DebugToggleTile( child: DebugToggleTile(
@@ -5,6 +5,7 @@ import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/config/app_video_note_quality.dart';
import '../../../../core/media/native_video_note_recorder.dart'; import '../../../../core/media/native_video_note_recorder.dart';
import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/haptics.dart';
import '../../../../core/utils/logger.dart'; import '../../../../core/utils/logger.dart';
@@ -35,6 +36,10 @@ class VideoNoteController {
Timer? _timer; Timer? _timer;
bool _cancelled = false; bool _cancelled = false;
bool _stopRequested = false; bool _stopRequested = false;
bool? _frontOverride;
bool _switchingCamera = false;
bool get _front => _frontOverride ?? !AppVideoNoteRearCamera.current.value;
OverlayEntry? _overlay; OverlayEntry? _overlay;
ValueListenable<bool> get videoNoteMode => _videoNoteMode; ValueListenable<bool> get videoNoteMode => _videoNoteMode;
@@ -59,7 +64,11 @@ class VideoNoteController {
return; return;
} }
try { try {
final ok = await _rec.init(); final ok = await _rec.init(
front: _front,
size: AppVideoNoteResolution.current.value,
fps: AppVideoNoteFps.current.value,
);
if (!ok) { if (!ok) {
if (isMounted()) { if (isMounted()) {
showCustomNotification(contextOf(), 'Камера недоступна'); showCustomNotification(contextOf(), 'Камера недоступна');
@@ -124,6 +133,20 @@ class VideoNoteController {
} }
} }
Future<void> 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) { void handleDrag(Offset offsetFromOrigin) {
if (!_isRecording.value) return; if (!_isRecording.value) return;
final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold) final drag = (-offsetFromOrigin.dx / VoiceRecordController.cancelThreshold)
@@ -164,7 +187,7 @@ class VideoNoteController {
return; return;
} }
// Файл уже квадратный 480×480 (нативная запись) — шлём как есть. // Файл уже квадратный (нативная запись) — шлём как есть.
await onRecorded(File(path), elapsed); await onRecorded(File(path), elapsed);
} }
@@ -174,14 +197,15 @@ class VideoNoteController {
builder: (context) { builder: (context) {
final texId = _textureId.value; final texId = _textureId.value;
return Positioned.fill( return Positioned.fill(
child: IgnorePointer( child: Container(
child: Container( color: Colors.black.withValues(alpha: 0.55),
color: Colors.black.withValues(alpha: 0.55), alignment: Alignment.center,
alignment: Alignment.center, child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ GestureDetector(
ClipOval( onTap: flipCamera,
child: ClipOval(
child: SizedBox( child: SizedBox(
width: 260, width: 260,
height: 260, height: 260,
@@ -190,31 +214,36 @@ class VideoNoteController {
: Container(color: Colors.black), : Container(color: Colors.black),
), ),
), ),
const SizedBox(height: 20), ),
ValueListenableBuilder<int>( const SizedBox(height: 20),
valueListenable: _elapsedMs, ValueListenableBuilder<int>(
builder: (context, ms, _) => Text( valueListenable: _elapsedMs,
formatElapsed(ms), builder: (context, ms, _) => Text(
style: const TextStyle( formatElapsed(ms),
color: Colors.white, style: const TextStyle(
fontSize: 18, color: Colors.white,
fontFeatures: [ui.FontFeature.tabularFigures()], fontSize: 18,
), fontFeatures: [ui.FontFeature.tabularFigures()],
), ),
), ),
const SizedBox(height: 8), ),
ValueListenableBuilder<double>( const SizedBox(height: 8),
valueListenable: _cancelDrag, ValueListenableBuilder<double>(
builder: (context, drag, _) => Opacity( valueListenable: _cancelDrag,
opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0), builder: (context, drag, _) => Opacity(
child: const Text( opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0),
' влево — отмена', child: const Text(
style: TextStyle(color: Colors.white70, fontSize: 13), ' влево — отмена',
), style: TextStyle(color: Colors.white70, fontSize: 13),
), ),
), ),
], ),
), const SizedBox(height: 4),
const Text(
'тап по кружку — сменить камеру',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
],
), ),
), ),
); );
@@ -2325,27 +2325,29 @@ class _ChatListScreenState extends State<ChatListScreen>
onSend: (photos, caption) async { onSend: (photos, caption) async {
if (photos.isEmpty) return; if (photos.isEmpty) return;
final picked = photos.first; final picked = photos.first;
if (picked.item.isVideo) { final isVideo = picked.item.isVideo;
if (mounted) {
showCustomNotification(
context,
'Видео в историях пока не поддерживается',
);
}
return;
}
final file = final file =
picked.editedFile ?? picked.editedFile ??
picked.item.localFile ?? picked.item.localFile ??
await picked.item.originFile(); await picked.item.originFile();
if (file == null) { if (file == null) {
if (mounted) { if (mounted) {
showCustomNotification(context, 'Не удалось открыть фото'); showCustomNotification(
context,
isVideo ? 'Не удалось открыть видео' : 'Не удалось открыть фото',
);
} }
return; return;
} }
if (!mounted) return; if (!mounted) return;
pushSwipeable(context, (_) => StoryComposerScreen(file: file)); pushSwipeable(
context,
(_) => StoryComposerScreen(
file: file,
isVideo: isVideo,
durationMs: picked.item.duration?.inMilliseconds,
),
);
}, },
); );
} }
+10 -1
View File
@@ -5423,9 +5423,18 @@ class _ChatScreenState extends State<ChatScreen>
unawaited(_persistOutgoing(real, removeId: tempId)); unawaited(_persistOutgoing(real, removeId: tempId));
} }
_disposePhotoProgress(tempId); _disposePhotoProgress(tempId);
} catch (_) { } catch (e) {
logger.w('sendVideoNote failed: $e');
if (mounted) { if (mounted) {
_failPhotoMessage(tempId); _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 { } else {
_disposePhotoProgress(tempId); _disposePhotoProgress(tempId);
} }
@@ -1,19 +1,28 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:video_player/video_player.dart';
import '../../../core/utils/haptics.dart'; import '../../../core/utils/haptics.dart';
import '../../../main.dart' show fileUploader, messagesModule, storiesModule; import '../../../main.dart' show fileUploader, messagesModule, storiesModule;
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/primary_loading_button.dart'; import '../../widgets/primary_loading_button.dart';
const int _storyExpiration = 86400; const int _storyExpiration = 86400000;
class StoryComposerScreen extends StatefulWidget { class StoryComposerScreen extends StatefulWidget {
final File file; 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 @override
State<StoryComposerScreen> createState() => _StoryComposerScreenState(); State<StoryComposerScreen> createState() => _StoryComposerScreenState();
@@ -22,10 +31,29 @@ class StoryComposerScreen extends StatefulWidget {
class _StoryComposerScreenState extends State<StoryComposerScreen> { class _StoryComposerScreenState extends State<StoryComposerScreen> {
final ValueNotifier<bool> _publishing = ValueNotifier<bool>(false); final ValueNotifier<bool> _publishing = ValueNotifier<bool>(false);
int _audience = 1; // 1 = все, 2 = контакты int _audience = 1; // 1 = все, 2 = контакты
VideoPlayerController? _video;
@override
void initState() {
super.initState();
if (widget.isVideo) _initVideo();
}
Future<void> _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 @override
void dispose() { void dispose() {
_publishing.dispose(); _publishing.dispose();
_video?.dispose();
super.dispose(); super.dispose();
} }
@@ -33,37 +61,80 @@ class _StoryComposerScreenState extends State<StoryComposerScreen> {
if (_publishing.value) return; if (_publishing.value) return;
_publishing.value = true; _publishing.value = true;
try { try {
final url = await messagesModule.requestPhotoUploadUrl(); if (widget.isVideo) {
if (url == null || url.isEmpty) { await _publishVideo();
_fail('Не удалось получить адрес загрузки'); } else {
return; 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) { } catch (e) {
_fail(e.toString()); _fail(e.toString());
} }
} }
Future<void> _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<void> _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) { void _fail(String message) {
if (!mounted) { if (!mounted) {
_publishing.value = false; _publishing.value = false;
@@ -74,6 +145,48 @@ class _StoryComposerScreenState extends State<StoryComposerScreen> {
showCustomNotification(context, message); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -81,9 +194,7 @@ class _StoryComposerScreenState extends State<StoryComposerScreen> {
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Center( Center(child: _buildPreview()),
child: Image.file(widget.file, fit: BoxFit.contain),
),
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
+7
View File
@@ -38,6 +38,7 @@ import 'core/config/app_phonebook_names.dart';
import 'core/contacts/device_contacts_service.dart'; import 'core/contacts/device_contacts_service.dart';
import 'core/config/app_link_preview.dart'; import 'core/config/app_link_preview.dart';
import 'core/config/app_media_cache.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_pill_gradient.dart';
import 'core/config/app_visual_style.dart'; import 'core/config/app_visual_style.dart';
import 'core/config/app_chat_chrome.dart'; import 'core/config/app_chat_chrome.dart';
@@ -219,6 +220,9 @@ void main(List<String> args) async {
final phonebookNamesFuture = AppPhonebookNames.load(); final phonebookNamesFuture = AppPhonebookNames.load();
final linkPreviewFuture = AppLinkPreview.load(); final linkPreviewFuture = AppLinkPreview.load();
final cacheLimitFuture = AppMediaCacheLimit.load(); final cacheLimitFuture = AppMediaCacheLimit.load();
final videoNoteResolutionFuture = AppVideoNoteResolution.load();
final videoNoteFpsFuture = AppVideoNoteFps.load();
final videoNoteRearCameraFuture = AppVideoNoteRearCamera.load();
final digitalIdNativeFuture = AppDigitalIdNative.load(); final digitalIdNativeFuture = AppDigitalIdNative.load();
final showExtraInfoFuture = AppShowExtraInfo.load(); final showExtraInfoFuture = AppShowExtraInfo.load();
final trafficCaptureFuture = TrafficMonitor.instance.load(); final trafficCaptureFuture = TrafficMonitor.instance.load();
@@ -275,6 +279,9 @@ void main(List<String> args) async {
phonebookNamesFuture, phonebookNamesFuture,
linkPreviewFuture, linkPreviewFuture,
cacheLimitFuture, cacheLimitFuture,
videoNoteResolutionFuture,
videoNoteFpsFuture,
videoNoteRearCameraFuture,
digitalIdNativeFuture, digitalIdNativeFuture,
showExtraInfoFuture, showExtraInfoFuture,
]); ]);