Merge branch 'feature/voice-video-notes' into feature/FullStack

This commit is contained in:
klockky
2026-06-25 17:36:01 +03:00
20 changed files with 3056 additions and 87 deletions
+3
View File
@@ -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")
}
@@ -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<Boolean>("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<String>("input")
val output = call.argument<String>("output")
val size = call.argument<Int>("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<Effect>(
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<String, Any> {
@@ -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<Surface>,
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<EGLConfig>(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
}
}
+2
View File
@@ -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
+67 -1
View File
@@ -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<bool> 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<Socket> _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')
+197 -3
View File
@@ -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<AudioUploadInfo?> 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<Map<String, dynamic>?> 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 = <String, dynamic>{
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'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<String, dynamic>.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<VideoUploadInfo?> 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<Map<String, dynamic>?> 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 = <String, dynamic>{
'isLive': false,
'detectShare': false,
'elements': <dynamic>[],
'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<String, dynamic>.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);
}
@@ -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<bool> init({bool front = true}) async {
if (!isAvailable) return false;
try {
final res = await _channel.invokeMapMethod<String, dynamic>('init', {
'front': front,
});
textureId = res?['textureId'] as int?;
return textureId != null;
} catch (e) {
logger.w('NativeVideoNoteRecorder.init: $e');
return false;
}
}
Future<bool> start() async {
if (!isAvailable) return false;
try {
await _channel.invokeMethod('start');
return true;
} catch (e) {
logger.w('NativeVideoNoteRecorder.start: $e');
return false;
}
}
Future<String?> stop() async {
if (!isAvailable) return null;
try {
return await _channel.invokeMethod<String>('stop');
} catch (e) {
logger.w('NativeVideoNoteRecorder.stop: $e');
return null;
}
}
Future<void> dispose() async {
if (!isAvailable) return;
try {
await _channel.invokeMethod('dispose');
} catch (_) {}
textureId = null;
}
}
+248
View File
@@ -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<bool> 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<Uint8List?> 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 = <Uint8List>[];
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<Uint8List> 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 = <Uint8List>[];
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 = <Uint8List>[];
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<Uint8List> packets,
}) {
final segs = <int>[];
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;
}
}
+31
View File
@@ -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<String?> 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<String>('cropSquare', {
'input': input,
'output': output,
'size': size,
});
return res;
} catch (e) {
logger.w('VideoNoteCropper: $e');
return null;
}
}
}
File diff suppressed because it is too large Load Diff
+397 -55
View File
@@ -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<int> _amps = _parseWave(widget.waveData);
static List<int> _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<void> _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<double>(
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<double>(
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<void> _requestTranscription() async {
if (widget.audioId == null) return;
@@ -2856,3 +2944,257 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
}
}
}
class _WaveformPainter extends CustomPainter {
final List<int> 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<void> _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,
),
),
],
),
),
);
}
}
+4
View File
@@ -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<Locale> _loadInitialLocale() async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
fvp.registerWith(options: {
'platforms': ['windows', 'linux', 'macos'],
});
if (AppInstance.isNamed) {
SharedPreferences.setPrefix('flutter.${AppInstance.id}.');
}
+8
View File
@@ -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<String, dynamic> 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,
};
}
+2
View File
@@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+2
View File
@@ -6,5 +6,7 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+176 -8
View File
@@ -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:
+7
View File
@@ -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
+7
View File
@@ -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}"
Binary file not shown.
+44
View File
@@ -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/