diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1e61639..bbcc37b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -81,4 +81,7 @@ flutter { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + implementation("androidx.media3:media3-transformer:1.9.3") + implementation("androidx.media3:media3-effect:1.9.3") + implementation("androidx.media3:media3-common:1.9.3") } diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 462eaf7..ce24545 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -22,6 +22,22 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel +import android.media.MediaCodecInfo +import android.net.Uri +import androidx.media3.common.Effect +import androidx.media3.common.MediaItem +import androidx.media3.common.audio.ChannelMixingAudioProcessor +import androidx.media3.common.audio.ChannelMixingMatrix +import androidx.media3.effect.Presentation +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.Effects +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.Transformer +import androidx.media3.transformer.VideoEncoderSettings +import java.io.File import java.net.NetworkInterface import java.util.Collections import java.util.Random @@ -40,6 +56,7 @@ class MainActivity : FlutterActivity() { @Volatile private var nfcCycling = false private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) } + private var noteRecorder: VideoNoteRecorder? = null private var ble: BleContactExchange? = null private var pendingSelfId = 0L private var pendingSelfPhone = 0L @@ -195,6 +212,115 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/video_note", + ).setMethodCallHandler { call, result -> + when (call.method) { + "init" -> { + val front = call.argument("front") ?: true + val rec = VideoNoteRecorder(applicationContext, flutterEngine.renderer) + noteRecorder?.dispose() + noteRecorder = rec + rec.init(front, result) + } + "start" -> noteRecorder?.start(result) + ?: result.error("NOT_READY", "recorder not initialized", null) + "stop" -> noteRecorder?.stop(result) + ?: result.error("NOT_READY", "recorder not initialized", null) + "dispose" -> { + noteRecorder?.dispose() + noteRecorder = null + result.success(null) + } + else -> result.notImplemented() + } + } + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "ru.komet.app/video", + ).setMethodCallHandler { call, result -> + when (call.method) { + "cropSquare" -> { + val input = call.argument("input") + val output = call.argument("output") + val size = call.argument("size") ?: 480 + if (input == null || output == null) { + result.error("BAD_ARGS", "input/output required", null) + } else { + cropSquare(input, output, size, result) + } + } + else -> result.notImplemented() + } + } + } + + // Центр-кроп видео в квадрат size×size (без искажений) через media3 + // Transformer: LAYOUT_SCALE_TO_FIT_WITH_CROP заполняет квадрат и обрезает + // лишнее по бокам. + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun cropSquare( + input: String, + output: String, + size: Int, + result: MethodChannel.Result, + ) { + try { + // Параметры энкодера как у официального клиента: H.264 ~1 Мбит/с CBR. + val videoSettings = VideoEncoderSettings.Builder() + .setBitrate(1_024_000) + .setBitrateMode(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR) + .build() + val encoderFactory = DefaultEncoderFactory.Builder(this) + .setRequestedVideoEncoderSettings(videoSettings) + .build() + val transformer = Transformer.Builder(this) + .setEncoderFactory(encoderFactory) + .addListener(object : Transformer.Listener { + override fun onCompleted( + composition: Composition, + exportResult: ExportResult, + ) { + result.success(output) + } + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException, + ) { + result.error( + "TRANSCODE_FAILED", + exportException.message, + null, + ) + } + }) + .build() + // Аудио в моно (как у клиента). + val mono = ChannelMixingAudioProcessor() + mono.putChannelMixingMatrix(ChannelMixingMatrix.create(1, 1)) + mono.putChannelMixingMatrix(ChannelMixingMatrix.create(2, 1)) + val effects = Effects( + listOf(mono), + listOf( + Presentation.createForWidthAndHeight( + size, + size, + Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP, + ), + ), + ) + val edited = EditedMediaItem.Builder( + MediaItem.fromUri(Uri.fromFile(File(input))), + ).setEffects(effects).build() + transformer.start(edited, output) + } catch (e: Exception) { + result.error("TRANSCODE_FAILED", e.message, null) + } } private fun nfcStatus(): Map { diff --git a/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt new file mode 100644 index 0000000..7704453 --- /dev/null +++ b/android/app/src/main/kotlin/ru/komet/app/VideoNoteRecorder.kt @@ -0,0 +1,676 @@ +package ru.komet.app + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.SurfaceTexture +import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraDevice +import android.hardware.camera2.CameraManager +import android.hardware.camera2.CaptureRequest +import android.hardware.camera2.params.StreamConfigurationMap +import android.media.MediaRecorder +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLSurface +import android.opengl.GLES11Ext +import android.opengl.GLES20 +import android.opengl.Matrix +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import android.util.Size +import android.view.Surface +import androidx.core.content.ContextCompat +import io.flutter.plugin.common.MethodChannel +import io.flutter.view.TextureRegistry +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer + +// Нативная запись видео-кружка через GL-конвейер: камера выдаёт стандартный +// кадр в SurfaceTexture (OES), шейдер кропает по центру в квадрат и рендерит +// одновременно в превью (Flutter Texture) и в MediaRecorder (480×480, H.264, +// framework MediaMuxer). Так делает официальный клиент через CameraX — выход +// проходит серверный валидатор (media3-перекод его НЕ проходит). +class VideoNoteRecorder( + private val context: Context, + private val textureRegistry: TextureRegistry, +) { + private val tag = "VideoNoteRecorder" + private val edge = 480 + private val bitrate = 1_024_000 + private val fps = 30 + + private var cameraId = "" + private var lensFacing = CameraCharacteristics.LENS_FACING_FRONT + private var sensorOrientation = 270 + private var camSize = Size(1280, 720) + + private var cameraDevice: CameraDevice? = null + private var session: CameraCaptureSession? = null + private var recorder: MediaRecorder? = null + private var outputPath: String? = null + + private var camThread: HandlerThread? = null + private var camHandler: Handler? = null + private var glThread: HandlerThread? = null + private var glHandler: Handler? = null + + private var flutterEntry: TextureRegistry.SurfaceTextureEntry? = null + private var previewSurface: Surface? = null + private var recorderSurface: Surface? = null + + // GL state (живёт на glThread) + private var egl: EglCore? = null + private var previewWindow: WindowSurface? = null + private var recordWindow: WindowSurface? = null + private var oesTexId = 0 + private var camTexture: SurfaceTexture? = null + private var camInputSurface: Surface? = null + private var program: OesProgram? = null + private val stMatrix = FloatArray(16) + + @Volatile private var recording = false + @Volatile private var glReady = false + + private fun manager() = + context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + private fun selectCamera(facing: Int): Boolean { + val mgr = manager() + for (id in mgr.cameraIdList) { + val ch = mgr.getCameraCharacteristics(id) + if (ch.get(CameraCharacteristics.LENS_FACING) == facing) { + cameraId = id + lensFacing = facing + sensorOrientation = + ch.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 270 + val map = ch.get( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP, + ) + camSize = pickCamSize(map) + return true + } + } + return false + } + + // Поддерживаемый камерой размер вывода (для SurfaceTexture), близкий к 720p. + private fun pickCamSize(map: StreamConfigurationMap?): Size { + val sizes = map?.getOutputSizes(SurfaceTexture::class.java) + ?: return Size(1280, 720) + var best = sizes.firstOrNull() ?: Size(1280, 720) + var bestScore = Int.MAX_VALUE + for (s in sizes) { + val longSide = maxOf(s.width, s.height) + val shortSide = minOf(s.width, s.height) + if (shortSide < edge) continue + val score = kotlin.math.abs(longSide - 1280) + kotlin.math.abs(shortSide - 720) + if (score < bestScore) { + bestScore = score + best = s + } + } + return best + } + + fun init(facingFront: Boolean, rawResult: MethodChannel.Result) { + val result = OnceResult(rawResult) + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) { + result.error("NO_PERMISSION", "camera permission required", null) + return + } + try { + val facing = if (facingFront) { + CameraCharacteristics.LENS_FACING_FRONT + } else { + CameraCharacteristics.LENS_FACING_BACK + } + if (!selectCamera(facing) && + !selectCamera(CameraCharacteristics.LENS_FACING_BACK) + ) { + result.error("NO_CAMERA", "no camera found", null) + return + } + camThread = HandlerThread("VideoNoteCam").also { it.start() } + camHandler = Handler(camThread!!.looper) + glThread = HandlerThread("VideoNoteGL").also { it.start() } + glHandler = Handler(glThread!!.looper) + + val entry = textureRegistry.createSurfaceTexture() + flutterEntry = entry + entry.surfaceTexture().setDefaultBufferSize(edge, edge) + previewSurface = Surface(entry.surfaceTexture()) + + glHandler!!.post { + try { + setupGl() + glReady = true + openCamera(result, entry.id()) + } catch (e: Exception) { + Log.e(tag, "GL setup failed", e) + result.error("GL_FAILED", e.message, null) + } + } + } catch (e: Exception) { + Log.e(tag, "init failed", e) + result.error("INIT_FAILED", e.message, null) + } + } + + // === GL (на glThread) === + private fun setupGl() { + val core = EglCore() + egl = core + previewWindow = WindowSurface(core, previewSurface!!, core.displayConfig) + .also { it.makeCurrent() } + program = OesProgram() + oesTexId = program!!.createOesTexture() + val st = SurfaceTexture(oesTexId) + st.setDefaultBufferSize(camSize.width, camSize.height) + st.setOnFrameAvailableListener({ onFrame() }, glHandler) + camTexture = st + camInputSurface = Surface(st) + Log.i(tag, "GL setup ok cam=$camSize") + } + + private var frameCount = 0 + + private fun onFrame() { + val st = camTexture ?: return + val prog = program ?: return + val w = previewWindow ?: return + try { + w.makeCurrent() + st.updateTexImage() + st.getTransformMatrix(stMatrix) + } catch (e: Exception) { + Log.w(tag, "onFrame update: ${e.message}") + return + } + run { + GLES20.glViewport(0, 0, edge, edge) + prog.draw(oesTexId, stMatrix, camSize, lensFacing, true) + w.swap() + } + if (frameCount == 0) { + Log.i( + tag, + "first frame cam=$camSize orient=$sensorOrientation " + + "facing=$lensFacing st=[${stMatrix.joinToString(",") { "%.2f".format(it) }}]", + ) + } + frameCount++ + if (recording) { + recordWindow?.let { w -> + w.makeCurrent() + GLES20.glViewport(0, 0, edge, edge) + prog.draw(oesTexId, stMatrix, camSize, lensFacing, false) + w.setPresentationTime(System.nanoTime()) + w.swap() + } + } + } + + @Suppress("MissingPermission") + private fun openCamera(result: MethodChannel.Result, textureId: Long) { + manager().openCamera( + cameraId, + object : CameraDevice.StateCallback() { + override fun onOpened(device: CameraDevice) { + Log.i(tag, "camera opened $cameraId") + cameraDevice = device + startPreviewSession(result, textureId) + } + + override fun onDisconnected(device: CameraDevice) { + device.close(); cameraDevice = null + } + + override fun onError(device: CameraDevice, error: Int) { + device.close(); cameraDevice = null + result.error("CAMERA_ERROR", "code $error", null) + } + }, + camHandler, + ) + } + + private fun startPreviewSession(result: MethodChannel.Result, textureId: Long) { + val device = cameraDevice ?: return + val camSurface = camInputSurface ?: return + try { + createSession(listOf(camSurface)) { s -> + session = s + val req = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) + req.addTarget(camSurface) + s.setRepeatingRequest(req.build(), null, camHandler) + Log.i(tag, "preview session configured") + result.success(mapOf("textureId" to textureId, "size" to edge)) + } + } catch (e: Exception) { + Log.e(tag, "preview session failed", e) + result.error("PREVIEW_FAILED", e.message, null) + } + } + + private fun setupRecorder(path: String) { + val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") MediaRecorder() + } + rec.setAudioSource(MediaRecorder.AudioSource.MIC) + rec.setVideoSource(MediaRecorder.VideoSource.SURFACE) + rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + rec.setVideoEncoder(MediaRecorder.VideoEncoder.H264) + rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + rec.setVideoSize(edge, edge) + rec.setVideoEncodingBitRate(bitrate) + rec.setVideoFrameRate(fps) + rec.setAudioChannels(1) + rec.setAudioSamplingRate(48000) + rec.setAudioEncodingBitRate(96000) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + rec.setVideoEncodingProfileLevel( + android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh, + android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel3, + ) + } catch (e: Exception) { + Log.w(tag, "profile level: ${e.message}") + } + } + rec.setOutputFile(path) + rec.prepare() + recorder = rec + recorderSurface = rec.surface + } + + fun start(result: MethodChannel.Result) { + if (cameraDevice == null || !glReady) { + result.error("NOT_READY", "camera not initialized", null); return + } + try { + val path = File(context.cacheDir, "note_${System.nanoTime()}.mp4").absolutePath + outputPath = path + setupRecorder(path) + val recSurface = recorderSurface!! + glHandler!!.post { + try { + recordWindow = WindowSurface(egl!!, recSurface, egl!!.recordConfig) + recorder?.start() + recording = true + result.success(null) + } catch (e: Exception) { + Log.e(tag, "record window failed", e) + result.error("START_FAILED", e.message, null) + } + } + } catch (e: Exception) { + Log.e(tag, "start failed", e) + result.error("START_FAILED", e.message, null) + } + } + + fun stop(result: MethodChannel.Result) { + if (!recording) { + result.error("NOT_RECORDING", "no active recording", null); return + } + recording = false + glHandler!!.post { + try { + recordWindow?.release() + recordWindow = null + } catch (_: Exception) {} + try { + try { + recorder?.stop() + } catch (e: Exception) { + Log.w(tag, "recorder.stop: ${e.message}") + } + recorder?.reset(); recorder?.release(); recorder = null + recorderSurface = null + outputPath?.let { stripVideoEditList(it) } + result.success(outputPath) + } catch (e: Exception) { + Log.e(tag, "stop failed", e) + result.error("STOP_FAILED", e.message, null) + } + } + } + + // MediaRecorder добавляет на видео-трек edit list (edts/elst) из-за + // B-кадров — серверный валидатор такие файлы отвергает (у клиента видео без + // edit list). Переименовываем бокс edts→free (тот же размер, ничего не + // сдвигается, парсеры пропускают free) — edit list исчезает без перекода. + private fun stripVideoEditList(path: String) { + try { + val f = java.io.RandomAccessFile(path, "rw") + val scan = minOf(f.length(), 65536L).toInt() + val buf = ByteArray(scan) + f.seek(0); f.readFully(buf) + var i = 0 + while (i + 8 <= scan) { + if (buf[i] == 0x65.toByte() && buf[i + 1] == 0x64.toByte() && + buf[i + 2] == 0x74.toByte() && buf[i + 3] == 0x73.toByte() + ) { + f.seek(i.toLong()) + f.write(byteArrayOf(0x66, 0x72, 0x65, 0x65)) + Log.i(tag, "stripped video edit list at $i") + } + i++ + } + f.close() + } catch (e: Exception) { + Log.w(tag, "stripEditList: ${e.message}") + } + } + + fun dispose() { + recording = false + try { session?.close() } catch (_: Exception) {} + session = null + glHandler?.post { + try { recordWindow?.release() } catch (_: Exception) {} + try { recorder?.reset(); recorder?.release() } catch (_: Exception) {} + recorder = null + try { camTexture?.release() } catch (_: Exception) {} + try { camInputSurface?.release() } catch (_: Exception) {} + try { previewWindow?.release() } catch (_: Exception) {} + try { program?.release() } catch (_: Exception) {} + try { egl?.release() } catch (_: Exception) {} + } + cameraDevice?.close(); cameraDevice = null + previewSurface?.release(); previewSurface = null + flutterEntry?.release(); flutterEntry = null + glThread?.quitSafely(); glThread = null; glHandler = null + camThread?.quitSafely(); camThread = null; camHandler = null + } + + @Suppress("DEPRECATION") + private fun createSession( + surfaces: List, + onReady: (CameraCaptureSession) -> Unit, + ) { + val device = cameraDevice ?: return + device.createCaptureSession( + surfaces, + object : CameraCaptureSession.StateCallback() { + override fun onConfigured(s: CameraCaptureSession) = onReady(s) + override fun onConfigureFailed(s: CameraCaptureSession) { + Log.e(tag, "session config failed") + } + }, + camHandler, + ) + } +} + +private class OnceResult(private val inner: MethodChannel.Result) : MethodChannel.Result { + private val done = java.util.concurrent.atomic.AtomicBoolean(false) + override fun success(result: Any?) { + if (done.compareAndSet(false, true)) inner.success(result) + } + override fun error(code: String, message: String?, details: Any?) { + if (done.compareAndSet(false, true)) inner.error(code, message, details) + } + override fun notImplemented() { + if (done.compareAndSet(false, true)) inner.notImplemented() + } +} + +// ── Минимальный EGL/GL под рендер OES-текстуры в квадратные surface ── + +private class EglCore { + val display: EGLDisplay + val displayConfig: EGLConfig // без recordable — семплируется Flutter-текстурой + val recordConfig: EGLConfig // с recordable — для MediaRecorder-surface + val eglContext: EGLContext + + init { + display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + val ver = IntArray(2) + EGL14.eglInitialize(display, ver, 0, ver, 1) + // Два конфига: превью (Flutter Texture) НЕ должно иметь + // EGL_RECORDABLE_ANDROID — иначе буферы получают video-encoder usage + // и не семплируются как картинка (чёрное превью). А encoder-surface + // MediaRecorder, наоборот, требует recordable. + displayConfig = chooseConfig(false) + recordConfig = chooseConfig(true) + val ctxAttribs = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE) + eglContext = EGL14.eglCreateContext( + display, displayConfig, EGL14.EGL_NO_CONTEXT, ctxAttribs, 0, + ) + } + + private fun chooseConfig(recordable: Boolean): EGLConfig { + val attribs = if (recordable) { + intArrayOf( + EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + 0x3142, 1, EGL14.EGL_NONE, + ) + } else { + intArrayOf( + EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_NONE, + ) + } + val configs = arrayOfNulls(1) + val num = IntArray(1) + EGL14.eglChooseConfig(display, attribs, 0, configs, 0, 1, num, 0) + return configs[0]!! + } + + fun release() { + EGL14.eglMakeCurrent( + display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT, + ) + EGL14.eglDestroyContext(display, eglContext) + EGL14.eglReleaseThread() + EGL14.eglTerminate(display) + } +} + +private class WindowSurface( + private val core: EglCore, + surface: Surface, + config: EGLConfig, +) { + private var eglSurface: EGLSurface = + EGL14.eglCreateWindowSurface( + core.display, config, surface, intArrayOf(EGL14.EGL_NONE), 0, + ).also { + if (it == EGL14.EGL_NO_SURFACE) { + Log.w("VideoNoteRecorder", "eglCreateWindowSurface FAILED err=${EGL14.eglGetError()}") + } + } + + fun makeCurrent() { + EGL14.eglMakeCurrent(core.display, eglSurface, eglSurface, core.eglContext) + } + + fun swap() { + EGL14.eglSwapBuffers(core.display, eglSurface) + } + + fun setPresentationTime(ns: Long) { + EGLExt14.setPresentationTime(core.display, eglSurface, ns) + } + + fun release() { + EGL14.eglDestroySurface(core.display, eglSurface) + } +} + +private object EGLExt14 { + fun setPresentationTime(display: EGLDisplay, surface: EGLSurface, ns: Long) { + android.opengl.EGLExt.eglPresentationTimeANDROID(display, surface, ns) + } +} + +// Рисует OES-текстуру камеры в текущий квадратный surface, кропая центральный +// квадрат и учитывая ориентацию сенсора + зеркало фронталки. +private class OesProgram { + private val vertexShader = """ + attribute vec4 aPosition; + attribute vec4 aTexCoord; + uniform mat4 uTexMatrix; + varying vec2 vTex; + void main() { + gl_Position = aPosition; + vTex = (uTexMatrix * aTexCoord).xy; + } + """ + private val fragmentShader = """ + #extension GL_OES_EGL_image_external : require + precision mediump float; + varying vec2 vTex; + uniform samplerExternalOES sTexture; + void main() { + gl_FragColor = vec4(texture2D(sTexture, vTex).rgb, 1.0); + } + """ + + private val program: Int + private val aPosition: Int + private val aTexCoord: Int + private val uTexMatrix: Int + private val uTexture: Int + private val quad: FloatBuffer + private val tex: FloatBuffer + private val mirrorM = FloatArray(16) + private val cropM = FloatArray(16) + private val stMirrorM = FloatArray(16) + private val fullM = FloatArray(16) + + init { + program = buildProgram(vertexShader, fragmentShader) + aPosition = GLES20.glGetAttribLocation(program, "aPosition") + aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord") + uTexMatrix = GLES20.glGetUniformLocation(program, "uTexMatrix") + uTexture = GLES20.glGetUniformLocation(program, "sTexture") + quad = floatBuf(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) + tex = floatBuf(floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)) + } + + fun createOesTexture(): Int { + val ids = IntArray(1) + GLES20.glGenTextures(1, ids, 0) + val id = ids[0] + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, id) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR, + ) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR, + ) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE, + ) + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE, + ) + return id + } + + fun draw( + texId: Int, + st: FloatArray, + camSize: Size, + lensFacing: Int, + mirror: Boolean, + ) { + Matrix.setIdentityM(mirrorM, 0) + if (mirror && lensFacing == CameraCharacteristics.LENS_FACING_FRONT) { + Matrix.translateM(mirrorM, 0, 0.5f, 0.5f, 0f) + Matrix.scaleM(mirrorM, 0, -1f, 1f, 1f) + Matrix.translateM(mirrorM, 0, -0.5f, -0.5f, 0f) + } + val w = camSize.width.toFloat() + val h = camSize.height.toFloat() + Matrix.setIdentityM(cropM, 0) + Matrix.translateM(cropM, 0, 0.5f, 0.5f, 0f) + if (w >= h) { + Matrix.scaleM(cropM, 0, h / w, 1f, 1f) + } else { + Matrix.scaleM(cropM, 0, 1f, w / h, 1f) + } + Matrix.translateM(cropM, 0, -0.5f, -0.5f, 0f) + Matrix.multiplyMM(stMirrorM, 0, st, 0, mirrorM, 0) + Matrix.multiplyMM(fullM, 0, cropM, 0, stMirrorM, 0) + + GLES20.glClearColor(0f, 0f, 0f, 1f) + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) + GLES20.glUseProgram(program) + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId) + GLES20.glUniform1i(uTexture, 0) + GLES20.glUniformMatrix4fv(uTexMatrix, 1, false, fullM, 0) + GLES20.glEnableVertexAttribArray(aPosition) + GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 0, quad) + GLES20.glEnableVertexAttribArray(aTexCoord) + GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 0, tex) + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + GLES20.glDisableVertexAttribArray(aPosition) + GLES20.glDisableVertexAttribArray(aTexCoord) + } + + fun release() { + GLES20.glDeleteProgram(program) + } + + private fun floatBuf(data: FloatArray): FloatBuffer { + val bb = ByteBuffer.allocateDirect(data.size * 4).order(ByteOrder.nativeOrder()) + val fb = bb.asFloatBuffer() + fb.put(data).position(0) + return fb + } + + private fun buildProgram(vs: String, fs: String): Int { + val v = compile(GLES20.GL_VERTEX_SHADER, vs) + val f = compile(GLES20.GL_FRAGMENT_SHADER, fs) + val p = GLES20.glCreateProgram() + GLES20.glAttachShader(p, v) + GLES20.glAttachShader(p, f) + GLES20.glLinkProgram(p) + val status = IntArray(1) + GLES20.glGetProgramiv(p, GLES20.GL_LINK_STATUS, status, 0) + if (status[0] == 0) { + val log = GLES20.glGetProgramInfoLog(p) + GLES20.glDeleteProgram(p) + throw RuntimeException("link failed: $log") + } + return p + } + + private fun compile(type: Int, src: String): Int { + val s = GLES20.glCreateShader(type) + GLES20.glShaderSource(s, src) + GLES20.glCompileShader(s) + val status = IntArray(1) + GLES20.glGetShaderiv(s, GLES20.GL_COMPILE_STATUS, status, 0) + if (status[0] == 0) { + val log = GLES20.glGetShaderInfoLog(s) + GLES20.glDeleteShader(s) + throw RuntimeException("compile failed: $log") + } + return s + } +} diff --git a/android/gradle.properties b/android/gradle.properties index 0d35563..bab4e68 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,6 +2,8 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true kotlin.incremental=false dev.steenbakker.mobile_scanner.useUnbundled=true + +android.ndk.suppressMinSdkVersionError=21 # This builtInKotlin flag was added automatically by Flutter migrator android.builtInKotlin=false # This newDsl flag was added automatically by Flutter migrator diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index a324736..790a9b8 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -155,6 +155,71 @@ class FileUploader { return ctrl.stream; } + /// Загружает медиа (Ogg/Opus аудио или MP4 видеосообщение) на CDN-URL, + /// полученный из [MessagesModule.requestAudioUploadUrl] / + /// [MessagesModule.requestVideoNoteUploadUrl]. Одиночный POST всего файла + /// (`octet-stream`, `Content-Range` на весь объём, `filename=<число>`). + /// Токен уже известен, поэтому возвращается только признак успеха. + Future uploadMediaFile( + Uri uri, + File file, { + void Function(int sent, int total)? onProgress, + Duration overallTimeout = const Duration(minutes: 5), + Duration progressThrottle = const Duration(milliseconds: 16), + }) async { + Socket? socket; + try { + final total = await file.length(); + if (total <= 0) return false; + final filename = + (DateTime.now().microsecondsSinceEpoch & 0x7FFFFFFF).toString(); + + socket = await _openSocket(uri); + _writeHeaders( + socket, + uri, + filename, + total, + contentType: 'application/octet-stream', + connection: 'close', + ); + + final stopwatch = Stopwatch()..start(); + var sent = 0; + final body = file.openRead().map((chunk) { + sent += chunk.length; + if (onProgress != null && stopwatch.elapsed >= progressThrottle) { + onProgress(sent, total); + stopwatch.reset(); + } + return chunk; + }); + await socket.addStream(body); + await socket.flush(); + onProgress?.call(total, total); + + final response = await _readFullResponse(socket, timeout: overallTimeout); + try { + socket.destroy(); + } catch (_) {} + final statusCode = response?.$1 ?? 0; + final respBody = response?.$2 ?? ''; + logger.w( + 'uploadMediaFile: status=$statusCode total=$total ' + 'host=${uri.host} body=${respBody.length > 200 ? respBody.substring(0, 200) : respBody}', + ); + final hasError = + respBody.contains('error_msg') || respBody.contains('error_code'); + return statusCode == 200 && !hasError; + } catch (e) { + logger.w('uploadMediaFile: $e'); + try { + socket?.destroy(); + } catch (_) {} + return false; + } + } + Future _openSocket(Uri uri) async { final proxySettings = await ProxyConfig.load(); final base = proxySettings.isEnabled @@ -175,6 +240,7 @@ class FileUploader { String filename, int total, { String contentType = 'application/x-binary; charset=x-user-defined', + String connection = 'keep-alive', }) { final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; final headers = StringBuffer() @@ -182,7 +248,7 @@ class FileUploader { ..write('Host: ${uri.host}\r\n') ..write('Content-Type: $contentType\r\n') ..write('Content-Disposition: attachment; filename=$filename\r\n') - ..write('Connection: keep-alive\r\n') + ..write('Connection: $connection\r\n') ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') ..write('Content-Range: bytes 0-${total - 1}/$total\r\n') ..write('Content-Length: $total\r\n') diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 630c778..47b76a5 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -339,6 +339,18 @@ class ReplyInfo { } } +class AudioUploadInfo { + final String url; + final int audioId; + final String token; + + AudioUploadInfo({ + required this.url, + required this.audioId, + required this.token, + }); +} + class CachedMessage { final String id; final int accountId; @@ -954,7 +966,10 @@ class MessagesModule { if (response.isOk) return true; return false; } on PacketError catch (e) { - if (e.errorKey != 'attachment.not.ready') rethrow; + if (!(e.errorKey?.contains('not.ready') ?? false)) { + logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); + rethrow; + } if (attempt == maxAttempts - 1) return false; await Future.delayed(retryDelay); } @@ -1006,7 +1021,10 @@ class MessagesModule { } return null; } on PacketError catch (e) { - if (e.errorKey != 'attachment.not.ready') rethrow; + if (!(e.errorKey?.contains('not.ready') ?? false)) { + logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); + rethrow; + } if (attempt == maxAttempts - 1) return null; await Future.delayed(retryDelay); } @@ -1080,7 +1098,183 @@ class MessagesModule { } return null; } on PacketError catch (e) { - if (e.errorKey != 'attachment.not.ready') rethrow; + if (!(e.errorKey?.contains('not.ready') ?? false)) { + logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); + rethrow; + } + if (attempt == maxAttempts - 1) return null; + await Future.delayed(retryDelay); + } + } + return null; + } + + /// Запрашивает URL для загрузки голосового сообщения (опкод 82). + /// + /// Тот же опкод, что и у видео, но `uploaderType: 1, type: 2`. В ответе + /// `videoId` — это идентификатор аудио (`audioId`), а `token` уже выдан и + /// используется в [sendAudioMessage] после загрузки байтов. + Future requestAudioUploadUrl() async { + final response = await _api.sendRequest(Opcode.videoUpload, { + 'uploaderType': 1, + 'type': 2, + 'count': 1, + }); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return AudioUploadInfo( + url: info['url'] as String? ?? '', + audioId: info['videoId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + /// Отправляет голосовое сообщение по [token], полученному из + /// [requestAudioUploadUrl], после загрузки Ogg/Opus-байтов на CDN. + /// + /// [duration] — длительность в миллисекундах. [wave] — hex-строка амплитуд + /// для дорожки; если пусто, отправляется плоская (нулевая) волна, которую + /// сервер принимает. Сервер может ответить `attachment.not.ready`, пока + /// обрабатывает загрузку — запрос повторяется. + Future?> sendAudioMessage( + int chatId, + String token, { + required int duration, + Uint8List? wave, + bool notify = true, + int? scheduledTime, + int maxAttempts = 30, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + { + 'duration': duration, + '_type': 'AUDIO', + 'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80), + 'token': token, + }, + ], + }; + if (scheduledTime != null) { + message['delayedAttributes'] = { + 'timeToFire': scheduledTime, + 'notifySender': true, + }; + } + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + for (var attempt = 0; attempt < maxAttempts; attempt++) { + try { + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) return null; + final data = response.payload; + if (data is Map) { + final msg = data['message']; + if (msg is Map) return Map.from(msg); + } + return null; + } on PacketError catch (e) { + if (!(e.errorKey?.contains('not.ready') ?? false)) { + logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); + rethrow; + } + if (attempt == maxAttempts - 1) return null; + await Future.delayed(retryDelay); + } + } + return null; + } + + /// Запрашивает URL для загрузки видеосообщения-кружка (опкод 82, + /// `uploaderType: 1, type: 1`). Ответ — `vu.oneme.ru/uploadVideo` + token. + Future requestVideoNoteUploadUrl() async { + final response = await _api.sendRequest(Opcode.videoUpload, { + 'uploaderType': 1, + 'type': 1, + 'count': 1, + }); + if (!response.isOk) return null; + + final data = response.payload; + if (data is! Map) return null; + + final infoList = data['info'] as List?; + if (infoList == null || infoList.isEmpty) return null; + + final info = infoList.first; + if (info is! Map) return null; + + return VideoUploadInfo( + url: info['url'] as String? ?? '', + videoId: info['videoId'] as int? ?? 0, + token: info['token'] as String? ?? '', + ); + } + + /// Отправляет видеосообщение-кружок (`videoType: 1`) по [token], полученному + /// из [requestVideoNoteUploadUrl], после загрузки MP4-байтов на CDN. + /// + /// [duration] — длительность в мс. [wave] — амплитуды аудиодорожки (бинарь, + /// 80 байт; нули допустимы). [thumbhash] — компактный хеш превью (опционально, + /// сервер всё равно отдаёт собственный `previewData`). Повторяет запрос на + /// `attachment.not.ready`, пока CDN обрабатывает загрузку. + Future?> sendVideoNoteMessage( + int chatId, + String token, { + required int duration, + Uint8List? wave, + String? thumbhash, + bool notify = true, + int maxAttempts = 30, + Duration retryDelay = const Duration(seconds: 1), + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + { + 'duration': duration, + 'videoType': 1, + '_type': 'VIDEO', + 'wave': (wave != null && wave.isNotEmpty) ? wave : Uint8List(80), + 'token': token, + if (thumbhash != null && thumbhash.isNotEmpty) 'thumbhash': thumbhash, + }, + ], + }; + final payload = {'chatId': chatId, 'message': message, 'notify': notify}; + + for (var attempt = 0; attempt < maxAttempts; attempt++) { + try { + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) return null; + final data = response.payload; + if (data is Map) { + final msg = data['message']; + if (msg is Map) return Map.from(msg); + } + return null; + } on PacketError catch (e) { + if (!(e.errorKey?.contains('not.ready') ?? false)) { + logger.w('msgSend rejected: key=${e.errorKey} msg=${e.message}'); + rethrow; + } if (attempt == maxAttempts - 1) return null; await Future.delayed(retryDelay); } diff --git a/lib/core/media/native_video_note_recorder.dart b/lib/core/media/native_video_note_recorder.dart new file mode 100644 index 0000000..6b80f4b --- /dev/null +++ b/lib/core/media/native_video_note_recorder.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; + +/// Нативная запись видео-кружка (Android, Camera2 + MediaRecorder): пишет +/// квадрат 480×480 сразу при съёмке — как официальный клиент. Превью отдаётся +/// через Flutter [Texture] по [textureId]. media3-перекод не используется +/// (серверный валидатор принимает только нативно записанный MP4). +class NativeVideoNoteRecorder { + static const _channel = MethodChannel('ru.komet.app/video_note'); + + int? textureId; + bool get isAvailable => Platform.isAndroid; + + Future init({bool front = true}) async { + if (!isAvailable) return false; + try { + final res = await _channel.invokeMapMethod('init', { + 'front': front, + }); + textureId = res?['textureId'] as int?; + return textureId != null; + } catch (e) { + logger.w('NativeVideoNoteRecorder.init: $e'); + return false; + } + } + + Future start() async { + if (!isAvailable) return false; + try { + await _channel.invokeMethod('start'); + return true; + } catch (e) { + logger.w('NativeVideoNoteRecorder.start: $e'); + return false; + } + } + + Future stop() async { + if (!isAvailable) return null; + try { + return await _channel.invokeMethod('stop'); + } catch (e) { + logger.w('NativeVideoNoteRecorder.stop: $e'); + return null; + } + } + + Future dispose() async { + if (!isAvailable) return; + try { + await _channel.invokeMethod('dispose'); + } catch (_) {} + textureId = null; + } +} diff --git a/lib/core/media/opus_ogg_encoder.dart b/lib/core/media/opus_ogg_encoder.dart new file mode 100644 index 0000000..5b9c8be --- /dev/null +++ b/lib/core/media/opus_ogg_encoder.dart @@ -0,0 +1,248 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:opus_dart/opus_dart.dart'; + +import '../utils/logger.dart'; + +/// Кодирует PCM в Ogg/Opus через libopus (FFI) на платформах, где у системы нет +/// своего Opus-энкодера (Windows). Сырые Opus-пакеты выдаёт [opus_dart], а +/// Ogg-контейнер (страницы, лейсинг, CRC32, OpusHead/OpusTags) собирается здесь. +/// +/// Формат совпадает с тем, что шлёт оригинальный клиент: моно, 48000 Hz, +/// pre-skip 312, vendor «libopus unknown». +class OpusOggEncoder { + static const int _sampleRate = 48000; + static const int _channels = 1; + static const int _preSkip = 312; + static const int _frameSamples = 960; // 20 мс @ 48 кГц + static const int _serial = 0x4b6f6d74; // 'Komt' + static const String _vendor = 'libopus unknown'; + + static bool _initialized = false; + static bool _available = false; + + /// Лениво загружает libopus и инициализирует opus_dart: на Windows — + /// вендоренную `opus.dll` рядом с exe, на Android — через + /// `opus_flutter_android`. Возвращает `false`, если кодек недоступен. + static Future ensureAvailable() async { + if (_initialized) return _available; + _initialized = true; + try { + // libopus.so на Android бандлится плагином opus_flutter_android, + // opus.dll — вендоренная рядом с exe на Windows. + final String libName; + if (Platform.isWindows) { + libName = 'opus.dll'; + } else if (Platform.isAndroid) { + libName = 'libopus.so'; + } else { + return false; + } + initOpus(DynamicLibrary.open(libName) as dynamic); + _available = true; + } catch (e) { + logger.w('OpusOggEncoder: libopus недоступна: $e'); + _available = false; + } + return _available; + } + + /// Парсит WAV (16-bit PCM моно 48 кГц) и кодирует его в Ogg/Opus. + /// Возвращает `null`, если кодек недоступен или WAV не распознан. + static Future wavToOggOpus(Uint8List wav) async { + if (!await ensureAvailable()) return null; + final pcm = _pcmFromWav(wav); + if (pcm == null || pcm.isEmpty) return null; + try { + return _encodePcm(pcm); + } catch (e) { + logger.w('OpusOggEncoder: ошибка кодирования: $e'); + return null; + } + } + + static Uint8List _encodePcm(Int16List pcm) { + final encoder = SimpleOpusEncoder( + sampleRate: _sampleRate, + channels: _channels, + application: Application.audio, + ); + final packets = []; + try { + for (var off = 0; off < pcm.length; off += _frameSamples) { + final end = off + _frameSamples; + final Int16List frame; + if (end <= pcm.length) { + frame = Int16List.sublistView(pcm, off, end); + } else { + frame = Int16List(_frameSamples)..setRange(0, pcm.length - off, pcm, off); + } + packets.add(encoder.encode(input: frame)); + } + } finally { + encoder.destroy(); + } + return _buildOgg(packets, totalSamples: pcm.length); + } + + static Uint8List _buildOgg(List packets, {required int totalSamples}) { + final out = BytesBuilder(); + var seq = 0; + + out.add(_page(headerType: 0x02, granulePos: 0, seq: seq++, packets: [_opusHead()])); + out.add(_page(headerType: 0x00, granulePos: 0, seq: seq++, packets: [_opusTags()])); + + var pagePackets = []; + var pageSegments = 0; + var samples = 0; + + void flush({required bool last}) { + final granule = last ? totalSamples + _preSkip : samples + _preSkip; + out.add(_page( + headerType: last ? 0x04 : 0x00, + granulePos: granule, + seq: seq++, + packets: pagePackets, + )); + pagePackets = []; + pageSegments = 0; + } + + for (var i = 0; i < packets.length; i++) { + final p = packets[i]; + final segs = (p.length ~/ 255) + 1; + if (pagePackets.isNotEmpty && pageSegments + segs > 255) { + flush(last: false); + } + pagePackets.add(p); + pageSegments += segs; + samples += _frameSamples; + } + flush(last: true); + + return out.toBytes(); + } + + static Uint8List _opusHead() { + final b = BytesBuilder(); + b.add(_ascii('OpusHead')); + final d = ByteData(11); + d.setUint8(0, 1); // version + d.setUint8(1, _channels); + d.setUint16(2, _preSkip, Endian.little); + d.setUint32(4, _sampleRate, Endian.little); + d.setUint16(8, 0, Endian.little); // output gain + d.setUint8(10, 0); // channel mapping family + b.add(d.buffer.asUint8List()); + return b.toBytes(); + } + + static Uint8List _opusTags() { + final vendor = _ascii(_vendor); + final b = BytesBuilder(); + b.add(_ascii('OpusTags')); + final len = ByteData(4)..setUint32(0, vendor.length, Endian.little); + b.add(len.buffer.asUint8List()); + b.add(vendor); + final count = ByteData(4)..setUint32(0, 0, Endian.little); + b.add(count.buffer.asUint8List()); + return b.toBytes(); + } + + static Uint8List _page({ + required int headerType, + required int granulePos, + required int seq, + required List packets, + }) { + final segs = []; + for (final p in packets) { + var len = p.length; + while (len >= 255) { + segs.add(255); + len -= 255; + } + segs.add(len); + } + + final header = Uint8List(27 + segs.length); + final hd = ByteData.sublistView(header); + header.setRange(0, 4, _ascii('OggS')); + hd.setUint8(4, 0); // stream structure version + hd.setUint8(5, headerType); + hd.setUint64(6, granulePos, Endian.little); + hd.setUint32(14, _serial, Endian.little); + hd.setUint32(18, seq, Endian.little); + hd.setUint32(22, 0, Endian.little); // CRC placeholder + hd.setUint8(26, segs.length); + header.setRange(27, 27 + segs.length, segs); + + final body = BytesBuilder(); + body.add(header); + for (final p in packets) { + body.add(p); + } + final page = body.toBytes(); + + final crc = _crc32(page); + ByteData.sublistView(page).setUint32(22, crc, Endian.little); + return page; + } + + static Uint8List _ascii(String s) => Uint8List.fromList(s.codeUnits); + + static final Uint32List _crcTable = _buildCrcTable(); + + static Uint32List _buildCrcTable() { + final t = Uint32List(256); + for (var i = 0; i < 256; i++) { + var r = (i << 24) & 0xffffffff; + for (var j = 0; j < 8; j++) { + if ((r & 0x80000000) != 0) { + r = ((r << 1) ^ 0x04c11db7) & 0xffffffff; + } else { + r = (r << 1) & 0xffffffff; + } + } + t[i] = r; + } + return t; + } + + static int _crc32(Uint8List data) { + var crc = 0; + for (final b in data) { + crc = (((crc << 8) & 0xffffffff) ^ _crcTable[((crc >> 24) & 0xff) ^ b]) & + 0xffffffff; + } + return crc & 0xffffffff; + } + + static Int16List? _pcmFromWav(Uint8List bytes) { + if (bytes.length < 12) return null; + if (String.fromCharCodes(bytes, 0, 4) != 'RIFF' || + String.fromCharCodes(bytes, 8, 12) != 'WAVE') { + return null; + } + final bd = ByteData.sublistView(bytes); + var off = 12; + while (off + 8 <= bytes.length) { + final id = String.fromCharCodes(bytes, off, off + 4); + final size = bd.getUint32(off + 4, Endian.little); + final body = off + 8; + if (id == 'data') { + final end = (body + size) <= bytes.length ? body + size : bytes.length; + final n = (end - body) ~/ 2; + final pcm = Int16List(n); + for (var i = 0; i < n; i++) { + pcm[i] = bd.getInt16(body + i * 2, Endian.little); + } + return pcm; + } + off = body + size + (size & 1); + } + return null; + } +} diff --git a/lib/core/media/video_note_cropper.dart b/lib/core/media/video_note_cropper.dart new file mode 100644 index 0000000..257ef0d --- /dev/null +++ b/lib/core/media/video_note_cropper.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../utils/logger.dart'; + +/// Центр-кроп записанного видео в квадрат для видеосообщений-кружков. +/// На Android выполняется нативно (media3 Transformer, без искажений — +/// заполняет квадрат и обрезает лишнее по бокам). На других платформах +/// возвращает `null` (кружки там не записываются). +class VideoNoteCropper { + static const _channel = MethodChannel('ru.komet.app/video'); + + static Future cropSquare(String input, {int size = 480}) async { + if (!Platform.isAndroid) return null; + try { + final dot = input.lastIndexOf('.'); + final base = dot > 0 ? input.substring(0, dot) : input; + final output = '${base}_sq.mp4'; + final res = await _channel.invokeMethod('cropSquare', { + 'input': input, + 'output': output, + 'size': size, + }); + return res; + } catch (e) { + logger.w('VideoNoteCropper: $e'); + return null; + } + } +} diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 5d91d17..ed3b2f8 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5,6 +5,8 @@ import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -13,6 +15,8 @@ import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/file_uploader.dart'; import 'package:komet/backend/modules/upload_notification_service.dart'; import 'package:komet/core/media/gallery_source.dart'; +import 'package:komet/core/media/opus_ogg_encoder.dart'; +import 'package:komet/core/media/native_video_note_recorder.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/core/utils/logger.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; @@ -81,6 +85,92 @@ class _MessageItem { const _MessageItem(this.message, this.index); } +class _RecordingDot extends StatefulWidget { + final Color color; + const _RecordingDot({required this.color}); + + @override + State<_RecordingDot> createState() => _RecordingDotState(); +} + +class _RecordingDotState extends State<_RecordingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 1.0, end: 0.25).animate(_c), + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), + ), + ); + } +} + +class _LiveWavePainter extends CustomPainter { + final List amps; + final Color color; + + const _LiveWavePainter({required this.amps, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + const slot = 5.0; + const barW = 3.0; + final count = (size.width / slot).floor(); + if (count <= 0 || amps.isEmpty) return; + + final start = amps.length > count ? amps.length - count : 0; + final visible = amps.sublist(start); + final center = size.height / 2; + final paint = Paint()..color = color; + final offset = size.width - visible.length * slot; + + for (var i = 0; i < visible.length; i++) { + final h = (visible[i] * size.height).clamp(2.0, size.height); + final x = offset + i * slot + (slot - barW) / 2; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, center - h / 2, barW, h), + const Radius.circular(barW / 2), + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_LiveWavePainter old) => true; +} + +class _ButtonClipper extends CustomClipper { + final double t; + const _ButtonClipper(this.t); + + @override + Rect getClip(Size size) { + if (t <= 0.001) { + return Rect.fromLTRB(-120, -260, size.width + 120, size.height + 40); + } + return Rect.fromLTRB(0, 0, size.width, size.height); + } + + @override + bool shouldReclip(_ButtonClipper old) => old.t != t; +} + class ChatScreen extends StatefulWidget { final int chatId; final String name; @@ -125,6 +215,35 @@ class _ChatScreenState extends State final Map>> _photoUploadProgress = {}; final ValueNotifier _scheduledCount = ValueNotifier(0); + AudioRecorder? _voiceRecorder; + final ValueNotifier _isRecordingVoice = ValueNotifier(false); + final ValueNotifier _voiceElapsedMs = ValueNotifier(0); + final ValueNotifier _voiceCancelDrag = ValueNotifier(0); + final ValueNotifier _voiceAmplitude = ValueNotifier(0); + final ValueNotifier _voiceWaveRev = ValueNotifier(0); + final ValueNotifier _voiceLocked = ValueNotifier(false); + final ValueNotifier _voiceLockDrag = ValueNotifier(0); + final Stopwatch _voiceStopwatch = Stopwatch(); + final List _voiceAmps = []; + Timer? _voiceTimer; + StreamSubscription? _voiceAmpSub; + String? _voicePath; + bool _voiceCancelled = false; + bool _voiceStopRequested = false; + bool _voiceTranscode = false; + + final ValueNotifier _videoNoteMode = ValueNotifier(false); + final NativeVideoNoteRecorder _noteRec = NativeVideoNoteRecorder(); + final ValueNotifier _noteTextureId = ValueNotifier(null); + final ValueNotifier _noteCamReady = ValueNotifier(false); + final ValueNotifier _isRecordingNote = ValueNotifier(false); + final ValueNotifier _noteElapsedMs = ValueNotifier(0); + final ValueNotifier _noteCancelDrag = ValueNotifier(0); + final Stopwatch _noteStopwatch = Stopwatch(); + Timer? _noteTimer; + bool _noteCancelled = false; + bool _noteStopRequested = false; + ValueListenable>? _photoProgressFor(CachedMessage m) => _photoUploadProgress[m.id]; @@ -715,6 +834,25 @@ class _ChatScreenState extends State _pushSub?.cancel(); _messageEventSub?.cancel(); _connSub?.cancel(); + _voiceTimer?.cancel(); + _voiceAmpSub?.cancel(); + _voiceRecorder?.dispose(); + _noteTimer?.cancel(); + _noteOverlay?.remove(); + _noteRec.dispose(); + _noteTextureId.dispose(); + _videoNoteMode.dispose(); + _noteCamReady.dispose(); + _isRecordingNote.dispose(); + _noteElapsedMs.dispose(); + _noteCancelDrag.dispose(); + _isRecordingVoice.dispose(); + _voiceElapsedMs.dispose(); + _voiceCancelDrag.dispose(); + _voiceAmplitude.dispose(); + _voiceWaveRev.dispose(); + _voiceLocked.dispose(); + _voiceLockDrag.dispose(); debugForceOffline.removeListener(_recomputeHeaderStatus); for (final n in _reactionNotifiers.values) { n.dispose(); @@ -3370,6 +3508,762 @@ class _ChatScreenState extends State ); } + static const int _voiceMinMs = 800; + static const double _voiceCancelThreshold = 110; + static const double _voiceLockThreshold = 90; + + Future _startVoiceRecording() async { + if (_isRecordingVoice.value || _myId == 0) return; + _voiceStopRequested = false; + final rec = _voiceRecorder ??= AudioRecorder(); + try { + final AudioEncoder encoder; + final String ext; + // Предпочитаем собственный кодер (libopus → Ogg/Opus): его формат сервер + // гарантированно принимает. Нативный Opus от record (напр. на Android) + // CDN не дообрабатывает — остаётся attachment.not.ready. + if (await OpusOggEncoder.ensureAvailable() && + await rec.isEncoderSupported(AudioEncoder.wav)) { + encoder = AudioEncoder.wav; + ext = 'wav'; + _voiceTranscode = true; + } else if (await rec.isEncoderSupported(AudioEncoder.opus)) { + encoder = AudioEncoder.opus; + ext = 'ogg'; + _voiceTranscode = false; + } else { + if (mounted) { + showCustomNotification( + context, + 'Голосовые сообщения недоступны на этой платформе', + ); + } + return; + } + if (!await rec.hasPermission()) { + if (mounted) showCustomNotification(context, 'Нет доступа к микрофону'); + return; + } + final dir = await getTemporaryDirectory(); + final path = + '${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.$ext'; + _voiceAmps.clear(); + _voiceCancelled = false; + _voicePath = path; + await rec.start( + RecordConfig( + encoder: encoder, + numChannels: 1, + sampleRate: 48000, + ), + path: path, + ); + if (!mounted) { + try { + await rec.stop(); + } catch (_) {} + return; + } + _voiceStopwatch + ..reset() + ..start(); + _voiceElapsedMs.value = 0; + _voiceCancelDrag.value = 0; + _voiceLocked.value = false; + _voiceLockDrag.value = 0; + _isRecordingVoice.value = true; + FocusManager.instance.primaryFocus?.unfocus(); + Haptics.send(); + _voiceTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { + _voiceElapsedMs.value = _voiceStopwatch.elapsedMilliseconds; + }); + _voiceAmpSub = rec + .onAmplitudeChanged(const Duration(milliseconds: 70)) + .listen((amp) { + final norm = ((amp.current + 45) / 45).clamp(0.0, 1.0); + _voiceAmps.add(norm); + _voiceAmplitude.value = norm; + _voiceWaveRev.value++; + }); + if (_voiceStopRequested) { + _voiceStopRequested = false; + await _stopVoiceRecording(cancel: false); + } + } catch (_) { + _isRecordingVoice.value = false; + if (mounted) showCustomNotification(context, 'Не удалось начать запись'); + } + } + + void _handleVoiceDrag(Offset offsetFromOrigin) { + if (!_isRecordingVoice.value || _voiceLocked.value) return; + + final lock = (-offsetFromOrigin.dy / _voiceLockThreshold).clamp(0.0, 1.0); + _voiceLockDrag.value = lock; + if (lock >= 1.0) { + _voiceLocked.value = true; + _voiceLockDrag.value = 0; + _voiceCancelDrag.value = 0; + Haptics.send(); + return; + } + + final drag = (-offsetFromOrigin.dx / _voiceCancelThreshold).clamp(0.0, 1.0); + _voiceCancelDrag.value = drag; + if (drag >= 1.0 && !_voiceCancelled) { + _voiceCancelled = true; + Haptics.error(); + _stopVoiceRecording(cancel: true); + } + } + + void _handleVoiceEnd() { + if (_voiceLocked.value) return; + _stopVoiceRecording(cancel: false); + } + + Future _stopVoiceRecording({required bool cancel}) async { + if (!_isRecordingVoice.value) { + _voiceStopRequested = true; + return; + } + final rec = _voiceRecorder; + if (rec == null) { + _isRecordingVoice.value = false; + return; + } + + _voiceTimer?.cancel(); + _voiceTimer = null; + await _voiceAmpSub?.cancel(); + _voiceAmpSub = null; + _voiceStopwatch.stop(); + final elapsed = _voiceStopwatch.elapsedMilliseconds; + _isRecordingVoice.value = false; + _voiceCancelDrag.value = 0; + _voiceAmplitude.value = 0; + _voiceLocked.value = false; + _voiceLockDrag.value = 0; + + String? path; + try { + path = await rec.stop(); + } catch (_) {} + path ??= _voicePath; + final amps = List.from(_voiceAmps); + _voiceAmps.clear(); + + final shouldCancel = cancel || _voiceCancelled || elapsed < _voiceMinMs; + if (shouldCancel || path == null) { + if (path != null) { + try { + await File(path).delete(); + } catch (_) {} + } + return; + } + + var file = File(path); + if (_voiceTranscode) { + final ogg = await _transcodeWavToOgg(file); + if (ogg == null) { + if (mounted) { + showCustomNotification(context, 'Не удалось закодировать запись'); + } + return; + } + file = ogg; + } + await _sendVoice(file, elapsed, amps); + } + + Future _transcodeWavToOgg(File wav) async { + try { + final bytes = await wav.readAsBytes(); + final ogg = await OpusOggEncoder.wavToOggOpus(bytes); + try { + await wav.delete(); + } catch (_) {} + if (ogg == null) return null; + final oggPath = '${wav.path.substring(0, wav.path.length - 3)}ogg'; + final out = File(oggPath); + await out.writeAsBytes(ogg, flush: true); + return out; + } catch (_) { + return null; + } + } + + Uint8List _buildWave(List amps, {int bars = 80}) { + final out = Uint8List(bars); + if (amps.isEmpty) return out; + for (var i = 0; i < bars; i++) { + final start = (i * amps.length / bars).floor(); + final end = (((i + 1) * amps.length / bars).ceil()).clamp( + start + 1, + amps.length, + ); + var peak = 0.0; + for (var j = start; j < end; j++) { + if (amps[j] > peak) peak = amps[j]; + } + out[i] = (peak * 120).round().clamp(0, 120); + } + return out; + } + + Future _sendVoice(File file, int durationMs, List amps) async { + if (_myId == 0) { + try { + await file.delete(); + } catch (_) {} + return; + } + final wave = _buildWave(amps); + final tempId = _nextTempId(); + final progress = ValueNotifier>(const [0]); + _photoUploadProgress[tempId] = progress; + _messages.add( + CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sending', + attachments: [AudioAttachment(duration: durationMs)], + ), + ); + _lastSentId = tempId; + _bumpMessages(); + Haptics.send(); + _scrollToBottom(); + + try { + try { + final len = await file.length(); + final head = await file + .openRead(0, 80) + .fold>([], (a, b) => a..addAll(b)); + final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final ascii = String.fromCharCodes( + head.map((b) => (b >= 32 && b < 127) ? b : 46), + ); + logger.w('VOICE size=$len hex=$hex ascii=$ascii'); + } catch (_) {} + final info = await messagesModule.requestAudioUploadUrl(); + if (info == null || info.url.isEmpty) throw Exception('no_url'); + + final ok = await fileUploader.uploadMediaFile( + Uri.parse(info.url), + file, + onProgress: (sent, total) { + if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; + }, + ); + if (!ok) throw Exception('upload_failed'); + if (!mounted) { + _disposePhotoProgress(tempId); + return; + } + + final serverMsg = await messagesModule.sendAudioMessage( + widget.chatId, + info.token, + duration: durationMs, + wave: wave, + ); + if (!mounted) { + _disposePhotoProgress(tempId); + return; + } + if (serverMsg == null) throw Exception('send_failed'); + + final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg); + final idx = _messages.indexWhere((m) => m.id == tempId); + if (idx != -1) { + _messages[idx] = real; + _bumpMessages(); + unawaited(_persistOutgoing(real, removeId: tempId)); + } + _disposePhotoProgress(tempId); + } catch (_) { + if (mounted) { + _failPhotoMessage(tempId); + } else { + _disposePhotoProgress(tempId); + } + } finally { + try { + await file.delete(); + } catch (_) {} + } + } + + // ── Видеосообщения-кружки ────────────────────────────────────────── + Future _toggleComposerMode() async { + final toVideo = !_videoNoteMode.value; + _videoNoteMode.value = toVideo; + Haptics.tap(); + if (toVideo) { + await _initNoteCamera(); + } else { + await _disposeNoteCamera(); + } + } + + Future _initNoteCamera() async { + if (_noteRec.textureId != null) return; + if (!_noteRec.isAvailable) { + if (mounted) showCustomNotification(context, 'Камера недоступна'); + return; + } + try { + final ok = await _noteRec.init(); + if (!ok) { + if (mounted) showCustomNotification(context, 'Камера недоступна'); + return; + } + if (!mounted || !_videoNoteMode.value) { + await _disposeNoteCamera(); + return; + } + _noteTextureId.value = _noteRec.textureId; + _noteCamReady.value = true; + } catch (e) { + logger.w('initNoteCamera: $e'); + if (mounted) showCustomNotification(context, 'Камера недоступна'); + } + } + + Future _disposeNoteCamera() async { + _noteCamReady.value = false; + _noteTextureId.value = null; + await _noteRec.dispose(); + } + + Future _startNoteRecording() async { + if (_isRecordingNote.value) return; + _noteStopRequested = false; + if (_noteRec.textureId == null) { + await _initNoteCamera(); + return; + } + try { + final ok = await _noteRec.start(); + if (!ok) { + _isRecordingNote.value = false; + return; + } + if (!mounted) { + await _noteRec.stop(); + return; + } + _noteStopwatch + ..reset() + ..start(); + _noteElapsedMs.value = 0; + _noteCancelDrag.value = 0; + _noteCancelled = false; + _isRecordingNote.value = true; + FocusManager.instance.primaryFocus?.unfocus(); + Haptics.send(); + _noteTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { + _noteElapsedMs.value = _noteStopwatch.elapsedMilliseconds; + }); + _showNoteOverlay(); + if (_noteStopRequested) { + _noteStopRequested = false; + await _stopNoteRecording(cancel: false); + } + } catch (e) { + logger.w('startNoteRecording: $e'); + _isRecordingNote.value = false; + } + } + + void _handleNoteDrag(Offset offsetFromOrigin) { + if (!_isRecordingNote.value) return; + final drag = (-offsetFromOrigin.dx / _voiceCancelThreshold).clamp(0.0, 1.0); + _noteCancelDrag.value = drag; + if (drag >= 1.0 && !_noteCancelled) { + _noteCancelled = true; + Haptics.error(); + _stopNoteRecording(cancel: true); + } + } + + void _handleNoteEnd() => _stopNoteRecording(cancel: false); + + Future _stopNoteRecording({required bool cancel}) async { + if (!_isRecordingNote.value) { + _noteStopRequested = true; + return; + } + _noteTimer?.cancel(); + _noteTimer = null; + _noteStopwatch.stop(); + final elapsed = _noteStopwatch.elapsedMilliseconds; + _isRecordingNote.value = false; + _noteCancelDrag.value = 0; + _hideNoteOverlay(); + + final path = await _noteRec.stop(); + + final shouldCancel = cancel || _noteCancelled || elapsed < _voiceMinMs; + if (shouldCancel || path == null) { + if (path != null) { + try { + await File(path).delete(); + } catch (_) {} + } + return; + } + + // Файл уже квадратный 480×480 (нативная запись) — шлём как есть. + await _sendVideoNote(File(path), elapsed); + } + + Future _sendVideoNote(File file, int durationMs) async { + if (_myId == 0) { + try { + await file.delete(); + } catch (_) {} + return; + } + final tempId = _nextTempId(); + final progress = ValueNotifier>(const [0]); + _photoUploadProgress[tempId] = progress; + _messages.add( + CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + time: DateTime.now().millisecondsSinceEpoch, + status: 'sending', + attachments: [VideoAttachment(duration: durationMs, videoType: 1)], + ), + ); + _lastSentId = tempId; + _bumpMessages(); + Haptics.send(); + _scrollToBottom(); + + try { + final info = await messagesModule.requestVideoNoteUploadUrl(); + if (info == null || info.url.isEmpty) throw Exception('no_url'); + final ok = await fileUploader.uploadMediaFile( + Uri.parse(info.url), + file, + onProgress: (sent, total) { + if (total > 0) progress.value = [(sent / total).clamp(0.0, 1.0)]; + }, + ); + if (!ok) throw Exception('upload_failed'); + if (!mounted) { + _disposePhotoProgress(tempId); + return; + } + final serverMsg = await messagesModule.sendVideoNoteMessage( + widget.chatId, + info.token, + duration: durationMs, + ); + if (!mounted) { + _disposePhotoProgress(tempId); + return; + } + if (serverMsg == null) throw Exception('send_failed'); + final real = CachedMessage.fromPushPayload(_myId, widget.chatId, serverMsg); + final idx = _messages.indexWhere((m) => m.id == tempId); + if (idx != -1) { + _messages[idx] = real; + _bumpMessages(); + unawaited(_persistOutgoing(real, removeId: tempId)); + } + _disposePhotoProgress(tempId); + } catch (_) { + if (mounted) { + _failPhotoMessage(tempId); + } else { + _disposePhotoProgress(tempId); + } + } finally { + try { + await file.delete(); + } catch (_) {} + } + } + + OverlayEntry? _noteOverlay; + + void _showNoteOverlay() { + _noteOverlay?.remove(); + _noteOverlay = OverlayEntry( + builder: (context) { + final texId = _noteTextureId.value; + return Positioned.fill( + child: IgnorePointer( + child: Container( + color: Colors.black.withValues(alpha: 0.55), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipOval( + child: SizedBox( + width: 260, + height: 260, + child: texId != null + ? Texture(textureId: texId) + : Container(color: Colors.black), + ), + ), + const SizedBox(height: 20), + ValueListenableBuilder( + valueListenable: _noteElapsedMs, + builder: (context, ms, _) => Text( + _formatVoiceElapsed(ms), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontFeatures: [ui.FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: _noteCancelDrag, + builder: (context, drag, _) => Opacity( + opacity: (0.5 + drag * 0.5).clamp(0.0, 1.0), + child: const Text( + '‹ влево — отмена', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + final overlay = Overlay.of(context, rootOverlay: true); + overlay.insert(_noteOverlay!); + } + + void _hideNoteOverlay() { + _noteOverlay?.remove(); + _noteOverlay = null; + } + + String _formatVoiceElapsed(int ms) { + final totalSec = ms ~/ 1000; + final m = (totalSec ~/ 60).toString(); + final s = (totalSec % 60).toString().padLeft(2, '0'); + final ds = ((ms % 1000) ~/ 100).toString(); + return '$m:$s,$ds'; + } + + Widget _recordingButtonVisual({ + required Widget pill, + required ColorScheme cs, + required bool active, + }) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: active ? 1.0 : 0.0), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + builder: (context, a, _) { + if (a <= 0.001) return pill; + return ValueListenableBuilder( + valueListenable: _voiceAmplitude, + builder: (context, amp, _) => TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: amp), + duration: const Duration(milliseconds: 110), + builder: (context, v, _) { + final glow = a * (88.0 + v * 76.0); + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Positioned( + left: 27 - glow / 2, + top: 27 - glow / 2, + child: Container( + width: glow, + height: glow, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.error.withValues(alpha: a * (0.16 + v * 0.12)), + ), + ), + ), + _voiceLockChip(cs), + Transform.scale( + scale: 1.0 + a * 0.14 + a * v * 0.24, + child: pill, + ), + ], + ); + }, + ), + ); + }, + ); + } + + Widget _voiceLockChip(ColorScheme cs) { + return Positioned( + bottom: 62, + child: ValueListenableBuilder( + valueListenable: _voiceLockDrag, + builder: (context, lock, _) => Opacity( + opacity: (0.5 + lock * 0.5).clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, lock * 12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6), + decoration: BoxDecoration( + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.96), + cs.surface, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 6, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.lock, + size: 16, + color: lock > 0.6 ? cs.primary : cs.onSurfaceVariant, + ), + Icon( + Symbols.keyboard_arrow_up, + size: 14, + color: cs.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildVoiceRecordingIndicator(ColorScheme cs) { + return Container( + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + ValueListenableBuilder( + valueListenable: _voiceAmplitude, + builder: (context, amp, child) => TweenAnimationBuilder( + tween: Tween(begin: 0, end: amp), + duration: const Duration(milliseconds: 120), + builder: (context, v, child) => Transform.scale( + scale: 1.0 + v * 0.7, + child: child, + ), + child: child, + ), + child: _RecordingDot(color: cs.error), + ), + const SizedBox(width: 12), + ValueListenableBuilder( + valueListenable: _voiceElapsedMs, + builder: (context, ms, _) => Text( + _formatVoiceElapsed(ms), + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontFeatures: const [ui.FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: ValueListenableBuilder( + valueListenable: _voiceCancelDrag, + builder: (context, drag, _) { + if (drag > 0.01) { + return Opacity( + opacity: (0.45 + drag * 0.55).clamp(0.0, 1.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon( + Symbols.arrow_back, + size: 16, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'Отмена', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ); + } + return SizedBox( + height: 26, + child: ValueListenableBuilder( + valueListenable: _voiceWaveRev, + builder: (context, _, _) => CustomPaint( + size: Size.infinite, + painter: _LiveWavePainter( + amps: _voiceAmps, + color: cs.primary.withValues(alpha: 0.85), + ), + ), + ), + ); + }, + ), + ), + const SizedBox(width: 8), + ValueListenableBuilder( + valueListenable: _voiceLocked, + builder: (context, locked, _) => locked + ? GestureDetector( + onTap: () => _stopVoiceRecording(cancel: true), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon(Symbols.delete, size: 22, color: cs.error), + ), + ) + : Text( + '‹ влево — отмена', + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 11, + ), + ), + ), + ], + ), + ); + } + Widget _buildInputArea(BuildContext context) { final cs = Theme.of(context).colorScheme; final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); @@ -3544,6 +4438,27 @@ class _ChatScreenState extends State ), ), ), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _isRecordingVoice, + builder: (context, recording, _) => IgnorePointer( + ignoring: !recording, + child: AnimatedSlide( + offset: recording + ? Offset.zero + : const Offset(0.06, 0), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: recording ? 1 : 0, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: _buildVoiceRecordingIndicator(cs), + ), + ), + ), + ), + ), ], ), ), @@ -3554,6 +4469,7 @@ class _ChatScreenState extends State builder: (context, child) { final t = _attachAnim.value; return ClipRect( + clipper: _ButtonClipper(t), child: Align( alignment: Alignment.centerLeft, widthFactor: (1 - t).clamp(0.0, 1.0), @@ -3579,27 +4495,91 @@ class _ChatScreenState extends State }, child: ValueListenableBuilder( valueListenable: _hasText, - builder: (context, hasText, _) => GlossyPill( - color: hasText - ? cs.primary - : cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(27), - onTap: hasText ? _sendMessage : null, - onLongPress: hasText ? _scheduleMessage : null, - depth: 8, - child: SizedBox( - width: 54, - height: 54, - child: Center( - child: Icon( - hasText ? Symbols.send : Symbols.mic, - color: hasText ? cs.onPrimary : cs.onSurface, - size: 24, - weight: 400, - ), + builder: (context, hasText, _) => + ValueListenableBuilder( + valueListenable: _voiceLocked, + builder: (context, locked, _) => + ValueListenableBuilder( + valueListenable: _isRecordingVoice, + builder: (context, recording, _) => + ValueListenableBuilder( + valueListenable: _videoNoteMode, + builder: (context, videoMode, _) { + final sendMode = hasText || locked; + final pill = GlossyPill( + color: sendMode + ? cs.primary + : recording + ? cs.error + : cs.surfaceContainerHighest, + borderRadius: + BorderRadius.circular(27), + onTap: hasText + ? _sendMessage + : locked + ? () => _stopVoiceRecording( + cancel: false, + ) + : null, + onLongPress: hasText + ? _scheduleMessage + : null, + depth: 8, + child: SizedBox( + width: 54, + height: 54, + child: Center( + child: Icon( + sendMode + ? Symbols.send + : videoMode + ? Symbols.videocam + : Symbols.mic, + color: sendMode + ? cs.onPrimary + : recording + ? cs.onError + : cs.onSurface, + size: 24, + weight: 400, + ), + ), + ), + ); + final visual = _recordingButtonVisual( + pill: pill, + cs: cs, + active: recording && !locked, + ); + return GestureDetector( + onTap: sendMode + ? null + : _toggleComposerMode, + onLongPressStart: sendMode + ? null + : (_) => videoMode + ? _startNoteRecording() + : _startVoiceRecording(), + onLongPressMoveUpdate: sendMode + ? null + : (d) => videoMode + ? _handleNoteDrag( + d.offsetFromOrigin, + ) + : _handleVoiceDrag( + d.offsetFromOrigin, + ), + onLongPressEnd: sendMode + ? null + : (_) => videoMode + ? _handleNoteEnd() + : _handleVoiceEnd(), + child: visual, + ); + }, + ), + ), ), - ), - ), ), ), ], diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index d87e282..ace8a74 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,6 +1,10 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:ogg_opus_player/ogg_opus_player.dart'; +import 'package:video_player/video_player.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -14,6 +18,7 @@ import '../../core/utils/bubble_radius.dart'; import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../core/utils/file_download.dart'; +import '../../core/utils/media_cache.dart'; import '../../core/utils/download_progress.dart'; import '../../core/utils/link_opener.dart'; import '../../core/config/app_link_preview.dart'; @@ -165,6 +170,13 @@ class MessageBubble extends StatelessWidget { return a != null && a.isNotEmpty && a.first is ShareAttachment; } + bool get _isVideoNote { + final a = message.attachments; + if (a == null || a.isEmpty) return false; + final first = a.first; + return first is VideoAttachment && first.isNote; + } + MessageType get _contentType { if (_hasShareAttachment) return _computeContentType(); return _contentTypeCache[message] ??= _computeContentType(); @@ -421,7 +433,10 @@ class MessageBubble extends StatelessWidget { prevMessage?.senderId != message.senderId; final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; - final bubbleColor = isMe ? cs.primaryContainer : cs.surfaceContainerHighest; + final isVideoNote = _isVideoNote; + final bubbleColor = isVideoNote + ? Colors.transparent + : (isMe ? cs.primaryContainer : cs.surfaceContainerHighest); _BubbleCtx makeCtx() => _BubbleCtx( context: context, @@ -507,13 +522,15 @@ class MessageBubble extends StatelessWidget { constraints: BoxConstraints(maxWidth: maxBubbleWidth), decoration: BoxDecoration( color: bubbleColor, - borderRadius: _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, - ), + borderRadius: isVideoNote + ? null + : _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, + ), ), padding: padding, child: child, @@ -1819,6 +1836,21 @@ class MessageBubble extends StatelessWidget { } Widget _buildVideoAttachment(_BubbleCtx ctx, MessageAttachment video) { + if (video is VideoAttachment && video.isNote) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _VideoNoteBubble( + attachment: video, + messageId: message.id, + chatId: message.chatId, + cs: ctx.cs, + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } final hasCaption = message.text != null && message.text!.isNotEmpty; final thumb = (video as dynamic).thumbnail as String?; final durationMs = (video as dynamic).duration as int?; @@ -2545,6 +2577,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { String? _transcriptionText; bool _transcriptionLoading = false; + OggOpusPlayer? _player; + bool _loadingAudio = false; + Timer? _ticker; + late final List _amps = _parseWave(widget.waveData); + + static List _parseWave(String? data) { + if (data == null || data.isEmpty) return const []; + return data.codeUnits; + } + @override void initState() { super.initState(); @@ -2553,10 +2595,73 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { @override void dispose() { + _ticker?.cancel(); + _player?.state.removeListener(_onPlayerState); + _player?.dispose(); _progress.dispose(); super.dispose(); } + Future _togglePlay() async { + if (_loadingAudio) return; + + if (_player != null) { + if (_isPlaying) { + _player!.pause(); + } else { + if (widget.duration > 0 && + _player!.currentPosition >= widget.duration - 0.05) { + _progress.value = 0; + } + _player!.play(); + } + return; + } + + final url = widget.url; + if (url.isEmpty) return; + + setState(() => _loadingAudio = true); + try { + final name = '${widget.audioId ?? widget.messageId}.ogg'; + final file = await MediaCache.getOrDownload(name, url); + if (!mounted) return; + if (file == null) { + showCustomNotification(context, 'Не удалось загрузить аудио'); + return; + } + final player = OggOpusPlayer(file.path); + _player = player; + player.state.addListener(_onPlayerState); + _ticker = Timer.periodic( + const Duration(milliseconds: 60), + (_) => _onTick(), + ); + player.play(); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка воспроизведения'); + } finally { + if (mounted) setState(() => _loadingAudio = false); + } + } + + void _onTick() { + final player = _player; + if (player == null || widget.duration <= 0) return; + final pos = player.currentPosition; + _progress.value = (pos / widget.duration).clamp(0.0, 1.0); + } + + void _onPlayerState() { + final state = _player?.state.value; + if (!mounted) return; + final playing = state == PlayerState.playing; + if (playing != _isPlaying) setState(() => _isPlaying = playing); + if (state == PlayerState.ended) { + _progress.value = 1.0; + } + } + Widget _buildStatusIcon() { final status = widget.status; IconData icon; @@ -2621,53 +2726,41 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { : widget.cs.primaryContainer, shape: BoxShape.circle, ), - child: Icon( - _isPlaying ? Symbols.pause : Symbols.play_arrow, - color: widget.isMe - ? widget.cs.onPrimaryContainer - : widget.cs.primary, - size: 18, - ), + child: _loadingAudio + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + color: widget.isMe + ? widget.cs.onPrimaryContainer + : widget.cs.primary, + ), + ) + : Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe + ? widget.cs.onPrimaryContainer + : widget.cs.primary, + size: 18, + ), ), ), const SizedBox(width: 10), Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return GestureDetector( - onTapDown: (details) { - _progress.value = - (details.localPosition.dx / constraints.maxWidth) - .clamp(0.0, 1.0); - }, - onHorizontalDragUpdate: (details) { - _progress.value = - (details.localPosition.dx / constraints.maxWidth) - .clamp(0.0, 1.0); - }, - child: Container( - height: 4, - decoration: BoxDecoration( - color: waveInactiveColor, - borderRadius: BorderRadius.circular(2), - ), - child: ValueListenableBuilder( - valueListenable: _progress, - builder: (context, progress, _) => - FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: progress.clamp(0.0, 1.0), - child: Container( - decoration: BoxDecoration( - color: waveActiveColor, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ), + child: SizedBox( + height: 26, + child: ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, _) => CustomPaint( + size: Size.infinite, + painter: _WaveformPainter( + amps: _amps, + progress: progress, + active: waveActiveColor, + inactive: waveInactiveColor, ), - ); - }, + ), + ), ), ), const SizedBox(width: 8), @@ -2795,11 +2888,6 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { ); } - void _togglePlay() { - setState(() { - _isPlaying = !_isPlaying; - }); - } Future _requestTranscription() async { if (widget.audioId == null) return; @@ -2856,3 +2944,257 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { } } } + +class _WaveformPainter extends CustomPainter { + final List amps; + final double progress; + final Color active; + final Color inactive; + + const _WaveformPainter({ + required this.amps, + required this.progress, + required this.active, + required this.inactive, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = size.height / 2; + + if (amps.isEmpty) { + final track = Paint() + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round; + canvas.drawLine( + Offset(0, center), + Offset(size.width, center), + track..color = inactive, + ); + if (progress > 0) { + canvas.drawLine( + Offset(0, center), + Offset(size.width * progress.clamp(0.0, 1.0), center), + track..color = active, + ); + } + return; + } + + final n = amps.length; + var maxAmp = 1; + for (final a in amps) { + if (a > maxAmp) maxAmp = a; + } + final slot = size.width / n; + final barW = (slot * 0.55).clamp(1.0, 3.0); + final paint = Paint(); + + for (var i = 0; i < n; i++) { + final h = ((amps[i] / maxAmp) * size.height).clamp(2.0, size.height); + final x = i * slot + (slot - barW) / 2; + paint.color = ((i + 0.5) / n) <= progress ? active : inactive; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, center - h / 2, barW, h), + Radius.circular(barW / 2), + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_WaveformPainter old) => + old.progress != progress || + old.active != active || + old.inactive != inactive || + !identical(old.amps, amps); +} + +class _VideoNoteBubble extends StatefulWidget { + final VideoAttachment attachment; + final String messageId; + final int chatId; + final ColorScheme cs; + + const _VideoNoteBubble({ + required this.attachment, + required this.messageId, + required this.chatId, + required this.cs, + }); + + @override + State<_VideoNoteBubble> createState() => _VideoNoteBubbleState(); +} + +class _VideoNoteBubbleState extends State<_VideoNoteBubble> { + static const double _size = 210; + VideoPlayerController? _controller; + bool _loading = false; + bool _error = false; + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + void _onTick() { + if (mounted) setState(() {}); + } + + static Uint8List? _previewBytes(String? data) { + if (data == null) return null; + const marker = 'base64,'; + final idx = data.indexOf(marker); + if (idx < 0) return null; + try { + return base64Decode(data.substring(idx + marker.length)); + } catch (_) { + return null; + } + } + + Future _toggle() async { + final existing = _controller; + if (existing != null) { + setState( + () => existing.value.isPlaying ? existing.pause() : existing.play(), + ); + return; + } + if (_loading) return; + + final a = widget.attachment; + final videoId = a.videoId; + final token = a.videoToken; + if (videoId == null || token == null) { + setState(() => _error = true); + return; + } + + setState(() => _loading = true); + Haptics.tap(); + try { + final cacheName = 'videonote_$videoId.mp4'; + var file = await MediaCache.existing(cacheName); + if (file == null) { + final url = await messagesModule.getVideoUrl( + messageId: widget.messageId, + chatId: widget.chatId, + token: token, + videoId: videoId, + ); + if (url == null) throw Exception('no_url'); + file = await MediaCache.getOrDownload(cacheName, url); + if (file == null) throw Exception('download'); + } + if (!mounted) return; + final c = VideoPlayerController.file(file); + _controller = c; + await c.initialize(); + if (!mounted) { + c.dispose(); + return; + } + await c.setLooping(true); + c.addListener(_onTick); + c.play(); + setState(() => _loading = false); + } catch (_) { + if (mounted) { + setState(() { + _loading = false; + _error = true; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final a = widget.attachment; + final c = _controller; + final ready = c != null && c.value.isInitialized; + final playing = ready && c.value.isPlaying; + final preview = _previewBytes(a.previewData); + + double progress = 0; + if (ready && c.value.duration.inMilliseconds > 0) { + progress = + c.value.position.inMilliseconds / c.value.duration.inMilliseconds; + } + + return GestureDetector( + onTap: _toggle, + child: SizedBox( + width: _size, + height: _size, + child: Stack( + alignment: Alignment.center, + children: [ + ClipOval( + child: SizedBox( + width: _size, + height: _size, + child: ready + ? FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: c.value.size.width, + height: c.value.size.height, + child: VideoPlayer(c), + ), + ) + : preview != null + ? Image.memory( + preview, + fit: BoxFit.cover, + gaplessPlayback: true, + ) + : Container(color: widget.cs.surfaceContainerHighest), + ), + ), + if (ready) + SizedBox( + width: _size - 2, + height: _size - 2, + child: CircularProgressIndicator( + value: progress.clamp(0.0, 1.0), + strokeWidth: 3, + color: widget.cs.primary, + backgroundColor: Colors.white24, + ), + ), + if (!playing) + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.black45, + shape: BoxShape.circle, + ), + child: _loading + ? const Padding( + padding: EdgeInsets.all(14), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Icon( + _error ? Symbols.error : Symbols.play_arrow, + color: Colors.white, + size: 30, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index d4c0cdf..61fa9cc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:fvp/fvp.dart' as fvp; import 'package:flutter/rendering.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:m3e_collection/m3e_collection.dart'; @@ -86,6 +87,9 @@ Future _loadInitialLocale() async { void main() async { WidgetsFlutterBinding.ensureInitialized(); + fvp.registerWith(options: { + 'platforms': ['windows', 'linux', 'macos'], + }); if (AppInstance.isNamed) { SharedPreferences.setPrefix('flutter.${AppInstance.id}.'); } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index c0eb1fb..01890f3 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -127,6 +127,11 @@ class VideoAttachment extends MessageAttachment { final int? duration; final int? size; + /// 0 — обычное видео, 1 — видеосообщение-кружок. + final int? videoType; + + bool get isNote => videoType == 1; + const VideoAttachment({ super.previewData, super.baseUrl, @@ -138,6 +143,7 @@ class VideoAttachment extends MessageAttachment { this.height, this.duration, this.size, + this.videoType, }) : super(type: AttachmentType.video); factory VideoAttachment.fromMap(Map map) { @@ -151,6 +157,7 @@ class VideoAttachment extends MessageAttachment { height: map['height'] as int?, duration: map['duration'] as int?, size: map['size'] as int?, + videoType: map['videoType'] as int?, ); } @@ -166,6 +173,7 @@ class VideoAttachment extends MessageAttachment { 'height': height, 'duration': duration, 'size': size, + 'videoType': videoType, }; } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 08c3ab1..8ac0d2a 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.device.audio-input + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7..b993342 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.device.audio-input + diff --git a/pubspec.lock b/pubspec.lock index d8bafad..2d2b75d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "1245a480a113437f8d46d19c0fb90cea9db921436d9cf2ba5fb11854a1312693" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" characters: dependency: transitive description: @@ -213,10 +253,10 @@ packages: dependency: transitive description: name: dbus - sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" url: "https://pub.dev" source: hosted - version: "0.7.14" + version: "0.7.13" device_info_plus: dependency: "direct main" description: @@ -573,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + fvp: + dependency: "direct main" + description: + name: fvp + sha256: "0f147516a2520bb37efc9027bce1f20a0aa4487428e9f1437ad7dfa449aa93bd" + url: "https://pub.dev" + source: hosted + version: "0.37.2" geolocator: dependency: "direct main" description: @@ -689,10 +737,10 @@ packages: dependency: "direct main" description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.9.1" intl: dependency: "direct main" description: @@ -721,10 +769,10 @@ packages: dependency: transitive description: name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.6.7" json_annotation: dependency: transitive description: @@ -909,6 +957,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + ogg_opus_player: + dependency: "direct main" + description: + name: ogg_opus_player + sha256: d9bba5c2e276ff13ceae1c216a2650560c805d6b06de4bf4eb608bc38f1b75f4 + url: "https://pub.dev" + source: hosted + version: "0.8.0" open_filex: dependency: "direct main" description: @@ -917,6 +973,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.0" + opus_dart: + dependency: "direct main" + description: + name: opus_dart + sha256: e8ab4774409997af33cbfdfe91a186854ed6458b2fec66b0e52f4434b1af2f7c + url: "https://pub.dev" + source: hosted + version: "3.0.1" + opus_flutter_android: + dependency: "direct main" + description: + name: opus_flutter_android + sha256: "9e5b71f5d959e1ee3b5faf7e1e893aabe7955d3a234c83f02462d801a5218497" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + opus_flutter_platform_interface: + dependency: transitive + description: + name: opus_flutter_platform_interface + sha256: "41180b74f1dacc131270d49815fd4eb46bd7cc5ad57aac84eab748d08de59138" + url: "https://pub.dev" + source: hosted + version: "3.0.0" package_config: dependency: transitive description: @@ -1053,6 +1133,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" record_use: dependency: transitive description: @@ -1061,6 +1189,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: @@ -1226,6 +1370,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -1242,6 +1394,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0+1" + system_clock: + dependency: transitive + description: + name: system_clock + sha256: "8133d7707b4bed8a5e62f0809f040975f8854e11b1c7ee851e56f929f153cc8a" + url: "https://pub.dev" + source: hosted + version: "2.0.1" term_glyph: dependency: transitive description: @@ -1418,6 +1578,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_ffi: + dependency: transitive + description: + name: web_ffi + sha256: "48ef8037f7bc051d11b88d0f2903e02bec21092c51833d37c3361c36e3edc4f7" + url: "https://pub.dev" + source: hosted + version: "0.7.2" webrtc_interface: dependency: transitive description: @@ -1454,10 +1622,10 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f86de49..b6c1647 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,13 @@ dependencies: flutter_local_notifications: ^22.0.1 flutter_inappwebview: ^6.1.5 flutter_webrtc: ^1.5.2 + record: ^6.0.0 + opus_dart: ^3.0.1 + opus_flutter_android: ^3.0.1 + ogg_opus_player: ^0.8.0 + fvp: ^0.37.2 + camera: ^0.11.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 4f40850..163cf91 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -92,6 +92,13 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Vendored libopus, used only on Windows to encode voice messages to Ogg/Opus +# (Windows has no system Opus encoder). Copied next to the executable so it can +# be loaded via DynamicLibrary.open('opus.dll'). +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/opus/opus.dll" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") install(DIRECTORY "${NATIVE_ASSETS_DIR}" diff --git a/windows/opus/opus.dll b/windows/opus/opus.dll new file mode 100644 index 0000000..23ff7fc Binary files /dev/null and b/windows/opus/opus.dll differ diff --git a/windows/opus/opus_license.txt b/windows/opus/opus_license.txt new file mode 100644 index 0000000..d3c8eb6 --- /dev/null +++ b/windows/opus/opus_license.txt @@ -0,0 +1,44 @@ +Copyright 2001-2011 Xiph.Org, Skype Limited, Octasic, + Jean-Marc Valin, Timothy B. Terriberry, + CSIRO, Gregory Maxwell, Mark Borgerding, + Erik de Castro Lopo + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of Internet Society, IETF or IETF Trust, nor the +names of specific contributors, may be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Opus is subject to the royalty-free patent licenses which are +specified at: + +Xiph.Org Foundation: +https://datatracker.ietf.org/ipr/1524/ + +Microsoft Corporation: +https://datatracker.ietf.org/ipr/1914/ + +Broadcom Corporation: +https://datatracker.ietf.org/ipr/1526/ \ No newline at end of file