android: 拍照MDT容器保存(MediaStore DCIM/MAG160C)+MP4录像(Surface编码) 阶段3b
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package com.mag160c.thermal.media
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* MDT thermal file container, mirroring the vendor ThermoScope layout
|
||||
* (docs/android_app/reverse_apk_features.md §4, all little-endian):
|
||||
*
|
||||
* [JPEG section: raw jpg bytes at offset 0, padded to 4]
|
||||
* [DDT section: 136B header (code 0x5BB5B55B + size + reserved 128) + body]
|
||||
* [Tail: 152B — code 0x5BB5B57B + ddtOffset + reserved]
|
||||
*
|
||||
* DDT body = typed blocks {u32 magic, u32 len, data padded to 4}:
|
||||
* 0x5BB5B55B camera info (0x38B from command 66b)
|
||||
* 0x5BB5B55C second info block (0x38B from 66c, optional)
|
||||
* 0x5BB5B55D raw measurement frame (19200 x uint16 LE)
|
||||
* 0x5BB5B55E text note (UTF-8, optional)
|
||||
*/
|
||||
object Mdt {
|
||||
const val SECTION_DDT = 0x5BB5B55B
|
||||
const val SECTION_TAIL = 0x5BB5B57B
|
||||
|
||||
const val BLOCK_INFO0 = 0x5BB5B55B
|
||||
const val BLOCK_INFO1 = 0x5BB5B55C
|
||||
const val BLOCK_FRAME = 0x5BB5B55D
|
||||
const val BLOCK_TXT = 0x5BB5B55E
|
||||
|
||||
private fun align4(n: Int): Int = (n + 3) / 4 * 4
|
||||
|
||||
fun u32(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or
|
||||
((b[off + 1].toInt() and 0xFF) shl 8) or
|
||||
((b[off + 2].toInt() and 0xFF) shl 16) or
|
||||
((b[off + 3].toInt() and 0xFF) shl 24)
|
||||
|
||||
fun put32(dst: ByteArray, off: Int, v: Int) {
|
||||
dst[off] = (v and 0xFF).toByte()
|
||||
dst[off + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
dst[off + 2] = ((v shr 16) and 0xFF).toByte()
|
||||
dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose an MDT file.
|
||||
* @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base)
|
||||
* @param info0 camera info block from command 0x6BB6B66B (0x38B)
|
||||
* @param info1 second cached info block (0x38B) or null
|
||||
* @param rawFrame latest raw USB frame (0x38-byte header + 38400B pixels)
|
||||
* @param text UTF-8 note bytes or null
|
||||
*/
|
||||
fun compose(
|
||||
jpg: ByteArray,
|
||||
info0: ByteArray?,
|
||||
info1: ByteArray?,
|
||||
rawFrame: ByteArray?,
|
||||
text: ByteArray? = null,
|
||||
): ByteArray {
|
||||
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
|
||||
out.write(jpg, 0, jpg.size)
|
||||
repeat(align4(jpg.size) - jpg.size) { out.write(0) }
|
||||
|
||||
// --- DDT section ---
|
||||
val ddtOffset = out.size()
|
||||
val body = ByteArrayOutputStream()
|
||||
fun emit(magic: Int, data: ByteArray) {
|
||||
val n = align4(data.size)
|
||||
val b = ByteArray(8 + n)
|
||||
put32(b, 0, magic)
|
||||
put32(b, 4, n)
|
||||
System.arraycopy(data, 0, b, 8, data.size)
|
||||
body.write(b, 0, b.size)
|
||||
}
|
||||
info0?.let { emit(BLOCK_INFO0, it) }
|
||||
info1?.let { emit(BLOCK_INFO1, it) }
|
||||
val raw = rawFrame
|
||||
if (raw != null && raw.size >= 0x1C + 38400) {
|
||||
emit(BLOCK_FRAME, raw.copyOfRange(0x1C, 0x1C + 38400))
|
||||
}
|
||||
text?.let { emit(BLOCK_TXT, it) }
|
||||
|
||||
val bodyBytes = body.toByteArray()
|
||||
val header = ByteArray(0x88)
|
||||
put32(header, 0, SECTION_DDT)
|
||||
put32(header, 4, bodyBytes.size)
|
||||
out.write(header, 0, 0x88)
|
||||
out.write(bodyBytes, 0, bodyBytes.size)
|
||||
|
||||
// --- Tail ---
|
||||
val tail = ByteArray(152)
|
||||
put32(tail, 0, SECTION_TAIL)
|
||||
put32(tail, 4, ddtOffset)
|
||||
out.write(tail, 0, 152)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** Parse an MDT file produced by [compose] (or any file whose last 152
|
||||
* bytes carry a valid tail). Returns the sections or null. */
|
||||
fun parse(bytes: ByteArray): Parsed? {
|
||||
if (bytes.size < 152) return null
|
||||
val tail = bytes.copyOfRange(bytes.size - 152, bytes.size)
|
||||
if (u32(tail, 0) != SECTION_TAIL) return null
|
||||
val ddtOffset = u32(tail, 4)
|
||||
if (ddtOffset <= 0 || ddtOffset + 0x88 > bytes.size - 152) return null
|
||||
if (u32(bytes, ddtOffset) != SECTION_DDT) return null
|
||||
val bodySize = u32(bytes, ddtOffset + 4)
|
||||
val bodyStart = ddtOffset + 0x88
|
||||
if (bodyStart + bodySize > bytes.size - 152) return null
|
||||
val blocks = HashMap<Int, ByteArray>()
|
||||
var p = bodyStart
|
||||
val end = bodyStart + bodySize
|
||||
while (p + 8 <= end) {
|
||||
val magic = u32(bytes, p)
|
||||
val len = u32(bytes, p + 4)
|
||||
if (len < 0 || p + 8 + len > end) break
|
||||
blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len)
|
||||
p += 8 + len
|
||||
}
|
||||
return Parsed(
|
||||
jpg = bytes.copyOfRange(0, ddtOffset),
|
||||
info0 = blocks[BLOCK_INFO0],
|
||||
info1 = blocks[BLOCK_INFO1],
|
||||
frame = blocks[BLOCK_FRAME],
|
||||
text = blocks[BLOCK_TXT],
|
||||
)
|
||||
}
|
||||
|
||||
class Parsed(
|
||||
val jpg: ByteArray,
|
||||
val info0: ByteArray?,
|
||||
val info1: ByteArray?,
|
||||
val frame: ByteArray?,
|
||||
val text: ByteArray?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.mag160c.thermal.media
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor
|
||||
* .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec
|
||||
* handles color conversion; frames arrive as ARGB bitmaps.
|
||||
*/
|
||||
class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
||||
private val fps = 15
|
||||
private val bitRate = 2_000_000
|
||||
|
||||
private var encoder: MediaCodec? = null
|
||||
private var inputSurface: android.view.Surface? = null
|
||||
private var muxer: MediaMuxer? = null
|
||||
private var trackIndex = -1
|
||||
private var muxerStarted = false
|
||||
private val active = AtomicBoolean(false)
|
||||
private val canvas = Canvas()
|
||||
private val paint = Paint()
|
||||
|
||||
@Volatile
|
||||
var outPath: File? = null
|
||||
private set
|
||||
|
||||
fun start(): Boolean {
|
||||
if (active.get()) return true
|
||||
return try {
|
||||
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
|
||||
)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
|
||||
}
|
||||
val enc = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
val surface = enc.createInputSurface()
|
||||
enc.start()
|
||||
encoder = enc
|
||||
inputSurface = surface
|
||||
val tmp = File.createTempFile("mag160c", ".mp4")
|
||||
muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
outPath = tmp
|
||||
active.set(true)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
releaseAll()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isRecording(): Boolean = active.get()
|
||||
|
||||
/** Push one frame bitmap (called from the frame callback thread). */
|
||||
fun offerFrame(bmp: Bitmap) {
|
||||
val surface = inputSurface ?: return
|
||||
if (!active.get()) return
|
||||
val c = surface.lockCanvas(null) ?: return
|
||||
try {
|
||||
c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
|
||||
} finally {
|
||||
surface.unlockCanvasAndPost(c)
|
||||
}
|
||||
drain(false)
|
||||
}
|
||||
|
||||
/** Stop recording and finalize. Returns the output file. */
|
||||
fun stop(): File? {
|
||||
if (!active.getAndSet(false)) return null
|
||||
val enc = encoder ?: return null
|
||||
// drain with EOS
|
||||
val idx = enc.dequeueInputBuffer(10_000)
|
||||
if (idx >= 0) enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
drain(true)
|
||||
val f = outPath
|
||||
runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
muxer = null
|
||||
runCatching { enc.stop() }
|
||||
runCatching { enc.release() }
|
||||
encoder = null
|
||||
inputSurface?.release()
|
||||
inputSurface = null
|
||||
return f
|
||||
}
|
||||
|
||||
private fun releaseAll() {
|
||||
runCatching { encoder?.stop() }
|
||||
runCatching { encoder?.release() }
|
||||
encoder = null
|
||||
runCatching { inputSurface?.release() }
|
||||
inputSurface = null
|
||||
runCatching { muxer?.stop() }
|
||||
runCatching { muxer?.release() }
|
||||
muxer = null
|
||||
}
|
||||
|
||||
private fun drain(end: Boolean) {
|
||||
val enc = encoder ?: return
|
||||
val mux = muxer ?: return
|
||||
val info = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val outIdx = enc.dequeueOutputBuffer(info, if (end) 10_000 else 0)
|
||||
when {
|
||||
outIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!end) return
|
||||
outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
trackIndex = mux.addTrack(enc.outputFormat)
|
||||
mux.start()
|
||||
muxerStarted = true
|
||||
}
|
||||
outIdx >= 0 -> {
|
||||
if (info.size > 0 && muxerStarted &&
|
||||
info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0
|
||||
) {
|
||||
val ob = enc.getOutputBuffer(outIdx)
|
||||
if (ob != null) mux.writeSampleData(trackIndex, ob, info)
|
||||
}
|
||||
enc.releaseOutputBuffer(outIdx, false)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.mag160c.thermal.media
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Save captured photos into MediaStore under DCIM/MAG160C (system gallery
|
||||
* visible, no rogue folders). The stored file is a self-contained MDT
|
||||
* container (JPG + temperature frame + note) named by capture time.
|
||||
*/
|
||||
object PhotoSaver {
|
||||
private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH)
|
||||
|
||||
fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg"
|
||||
|
||||
/** Encode a rendered ARGB frame to JPEG bytes. */
|
||||
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
|
||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
bmp.setPixels(frame, 0, w, 0, 0, w, h)
|
||||
val out = ByteArrayOutputStream(w * h / 4)
|
||||
bmp.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||
bmp.recycle()
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** Save an MDT container into MediaStore. Returns the media uri string. */
|
||||
fun saveMdt(
|
||||
context: Context,
|
||||
mdt: ByteArray,
|
||||
displayName: String,
|
||||
): String? {
|
||||
val resolver = context.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
val uri = resolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
|
||||
) ?: return null
|
||||
try {
|
||||
resolver.openOutputStream(uri)?.use { it.write(mdt) }
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
values.clear()
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
resolver.delete(uri, null, null)
|
||||
return null
|
||||
}
|
||||
return uri.toString()
|
||||
}
|
||||
|
||||
/** Save a plain JPEG (no MDT wrapper). */
|
||||
fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C")
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
val uri = context.contentResolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values,
|
||||
) ?: return null
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { it.write(jpg) }
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
|
||||
context.contentResolver.update(uri, done, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
context.contentResolver.delete(uri, null, null)
|
||||
return null
|
||||
}
|
||||
return uri.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -76,6 +76,7 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
||||
@Composable
|
||||
private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
|
||||
var showPalette by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Row(
|
||||
@@ -104,9 +105,18 @@ private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
|
||||
IconButton(onClick = { showPalette = true }) {
|
||||
Icon(painterResource(R.drawable.ic_palette), contentDescription = "调色板")
|
||||
}
|
||||
IconButton(onClick = { /* capture: phase 3b */ }) {
|
||||
IconButton(onClick = {
|
||||
val ctx = context
|
||||
vm.capturePhoto(ctx)
|
||||
}) {
|
||||
Icon(painterResource(R.drawable.ic_camera), contentDescription = "拍照")
|
||||
}
|
||||
IconButton(onClick = {
|
||||
val ctx = context
|
||||
vm.toggleRecording(ctx)
|
||||
}) {
|
||||
Icon(painterResource(R.drawable.ic_record), contentDescription = "录像")
|
||||
}
|
||||
Text(
|
||||
text = Palettes.NAMES[state.paletteIndex],
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
||||
@@ -56,6 +56,15 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
init {
|
||||
session.setListener(sessionListener)
|
||||
// push frames into the MP4 recorder while recording
|
||||
session.recorderHook = { argb ->
|
||||
val rec = recorder
|
||||
if (rec != null && rec.isRecording()) {
|
||||
val bmp = android.graphics.Bitmap.createBitmap(320, 240, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
bmp.setPixels(argb, 0, 320, 0, 0, 320, 240)
|
||||
rec.offerFrame(bmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin USB permission flow, then start streaming. */
|
||||
@@ -86,6 +95,44 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
|
||||
}
|
||||
|
||||
/** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */
|
||||
fun capturePhoto(context: android.content.Context) {
|
||||
val frame = latestFrame ?: return
|
||||
val s = session
|
||||
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame)
|
||||
val mdt = com.mag160c.thermal.media.Mdt.compose(
|
||||
jpg = jpg,
|
||||
info0 = s.lastInfo0,
|
||||
info1 = s.lastInfo1,
|
||||
rawFrame = s.lastRawFrame,
|
||||
)
|
||||
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
|
||||
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
|
||||
)
|
||||
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail")
|
||||
}
|
||||
|
||||
private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
|
||||
|
||||
/** Toggle MP4 recording of the live stream. */
|
||||
fun toggleRecording(context: android.content.Context) {
|
||||
val rec = recorder
|
||||
if (rec == null) {
|
||||
val r = com.mag160c.thermal.media.Mp4Recorder()
|
||||
if (r.start()) {
|
||||
recorder = r
|
||||
_state.value = _state.value.copy(status = "recording")
|
||||
}
|
||||
} else {
|
||||
val file = rec.stop()
|
||||
recorder = null
|
||||
if (file != null) {
|
||||
val name = "MAG160C_V_${com.mag160c.thermal.media.PhotoSaver.fileName()}"
|
||||
_state.value = _state.value.copy(status = "rec_done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Update per-frame temperature stats (called on a slow timer). */
|
||||
fun refreshTemps() {
|
||||
if (!session.isStreaming()) return
|
||||
|
||||
@@ -51,6 +51,10 @@ class IrSession(context: Context) {
|
||||
listener = l
|
||||
}
|
||||
|
||||
/** Optional per-frame hook (MP4 recording), runs on the reader thread. */
|
||||
@Volatile
|
||||
var recorderHook: ((IntArray) -> Unit)? = null
|
||||
|
||||
fun isStreaming(): Boolean = running.get()
|
||||
|
||||
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
|
||||
@@ -58,6 +62,15 @@ class IrSession(context: Context) {
|
||||
var lastRawFrame: ByteArray? = null
|
||||
private set
|
||||
|
||||
/** Cached 66b/66c camera info blocks (0x38B each) for the MDT DDT section. */
|
||||
@Volatile
|
||||
var lastInfo0: ByteArray? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var lastInfo1: ByteArray? = null
|
||||
private set
|
||||
|
||||
fun identitySnapshot(): CameraIdentity = identity.copy()
|
||||
|
||||
/** Connect + start the live stream. Must be called after USB permission. */
|
||||
@@ -122,8 +135,12 @@ class IrSession(context: Context) {
|
||||
val n = conn.bulkTransfer(resp, buf, buf.size, 2000)
|
||||
if (n <= 3) return
|
||||
val magic = MagProtocol.u32(buf, 0)
|
||||
if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) {
|
||||
lastInfo1 = buf.copyOfRange(4, 4 + 0x38)
|
||||
}
|
||||
if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) {
|
||||
val payload = buf.copyOfRange(4, n)
|
||||
lastInfo0 = payload.copyOf(0x38)
|
||||
val newIdentity = CameraIdentity(
|
||||
pid = MagProtocol.u32(payload, 0),
|
||||
serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or
|
||||
@@ -155,7 +172,10 @@ class IrSession(context: Context) {
|
||||
while (len > 0 && running.get()) {
|
||||
lastRawFrame = frameBuf.copyOf()
|
||||
val rendered = pipe.frame(frameBuf, true, out)
|
||||
if (rendered) listener?.onFrameReady(out)
|
||||
if (rendered) {
|
||||
listener?.onFrameReady(out)
|
||||
recorderHook?.invoke(out)
|
||||
}
|
||||
len = stream.push(noop, 0, frameBuf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M12,4C7.31,4 3.5,7.81 3.5,12.5S7.31,21 12,21s8.5,-3.81 8.5,-8.5S16.69,4 12,4zM12,18c-3.04,0 -5.5,-2.46 -5.5,-5.5S8.96,7 12,7s5.5,2.46 5.5,5.5S15.04,18 12,18zM12,17c2.49,0 4.5,-2.01 4.5,-4.5S14.49,8 12,8s-4.5,2.01 -4.5,4.5S9.51,17 12,17z" /></vector>
|
||||
Reference in New Issue
Block a user