feat(stories): публикация видео-историй + фикс TTL
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user