Merge branch 'feature/voice-video-notes' into feature/FullStack
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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}.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,5 +10,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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/
|
||||
Reference in New Issue
Block a user