Runt
2025-04-13 8c0f96b20f4cf44d230b373f51261566af02de8e
绘制文本功能
13 files modified
520 ■■■■ changed files
app/src/main/cpp/native-lib.cpp 101 ●●●● patch | view | raw | blame | history
app/src/main/cpp/pusher/live_pusher.cpp 31 ●●●●● patch | view | raw | blame | history
app/src/main/cpp/server_global.cpp 24 ●●●● patch | view | raw | blame | history
app/src/main/cpp/server_global.h 3 ●●●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/enum/StreamType.kt 2 ●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/media/CameraHelper.kt 23 ●●●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/media/ScreenHelper.kt 11 ●●●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/media/UVCHelper.kt 17 ●●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/ui/stream/LiveActivity.kt 15 ●●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/ui/stream/LiveLayoutView.kt 102 ●●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/ui/stream/LiveViewModel.kt 96 ●●●●● patch | view | raw | blame | history
app/src/main/java/com/runt/live/util/BitmapUtils.kt 94 ●●●●● patch | view | raw | blame | history
app/src/main/res/values/colors.xml 1 ●●●● patch | view | raw | blame | history
app/src/main/cpp/native-lib.cpp
@@ -115,7 +115,6 @@
void *task_pushFrames(void *args){
    int64_t time = getCurrentTimestamp();
    while (TRUE){
        YUVData *mainYuvData;
        int result = pushFrames.pop(mainYuvData);
        if(!result){
            continue;
@@ -186,37 +185,32 @@
            if(pushers.size() == 0){
                break;
            }
            if(width != FRAME_WIDTH){
                //缩放至1080
                uint8_t *dstData[3];
                copyYUV(mainYuvData->yuvData,width,height,dstData);
                delete mainYuvData->yuvData[0];
                delete mainYuvData->yuvData[1];
                delete mainYuvData->yuvData[2];
                //LOGI("缩放至1080");
                int64_t t1 = getCurrentTimestamp();
                scaleYUV(mainYuvData->yuvData,width,height,FRAME_WIDTH,FRAME_HEIGHT,dstData);
                scaleYUV(dstData,width,height,FRAME_WIDTH,FRAME_HEIGHT,mainYuvData->yuvData);
                //LOGI("缩放至1080 耗时:%d",getCurrentTimestamp() - t1);
                //加水印
                int64_t t2 = getCurrentTimestamp();
                waterYUV(dstData);
                //LOGI("加水印 耗时:%d",getCurrentTimestamp() - t2);
                //主画面
                int64_t t3 = getCurrentTimestamp();
                videoChannel->pushYUV(dstData);
                //LOGI("pushYUV 编码 耗时:%d", getCurrentTimestamp() - t3);
                delete dstData[0];
                delete dstData[1];
                delete dstData[2];
            }else{
                //加水印
                //LOGI("加水印");
                int64_t t2 = getCurrentTimestamp();
                waterYUV(mainYuvData->yuvData);
                //LOGI("加水印 耗时:%d",getCurrentTimestamp() - t2);
                //主画面
                //LOGI("pushYUV pushYUV %d",pushers.size());
                int64_t t1 = getCurrentTimestamp();
                videoChannel->pushYUV(mainYuvData->yuvData);
                //LOGI("pushYUV 编码 耗时:%d", getCurrentTimestamp() - t1);
            }
            //加水印
            //LOGI("加水印");
            int64_t t2 = getCurrentTimestamp();
            waterYUV(mainYuvData->yuvData);
            //LOGI("加水印 耗时:%d",getCurrentTimestamp() - t2);
            //主画面
            //LOGI("pushYUV pushYUV %d",pushers.size());
            int64_t t1 = getCurrentTimestamp();
            videoChannel->pushYUV(mainYuvData->yuvData);
            //LOGI("pushYUV 编码 耗时:%d", getCurrentTimestamp() - t1);
            //LOGI("pushYUV 耗时:%d", getCurrentTimestamp() - t);
        } while (0);
        releaseYuvFrameData(mainYuvData);
@@ -369,6 +363,9 @@
    if(!nativeMainWindow){
        LOGE("nativeWindow 回收");
    }
    if(!jvmMainCall){
        jvmMainCall = new JavaMainCall(env,clazz);
    }
}
extern "C"
@@ -443,4 +440,64 @@
    yuv[2] = reinterpret_cast<uint8_t *>(data + ySize + uSize); // V 平面
    pushYUV(stream_code,yuv);
    env->ReleaseByteArrayElements(bytes,data,0);
}
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_runt_live_cpp_LiveMiniView_native_1get_1cut_1frame(JNIEnv *env, jclass clazz, jint index, jint stream_code) {
    uint8_t *yuvData[3];
    if(mainYuvData){
        pthread_mutex_lock(&pushVideoMutex);
        copyYUV(mainYuvData->yuvData,FRAME_WIDTH,FRAME_HEIGHT,yuvData);
        pthread_mutex_unlock(&pushVideoMutex);
    }else{
        copyYUV(blackYUV,FRAME_WIDTH,FRAME_HEIGHT,yuvData);
    }
    waterYUV(index,yuvData);
    //*****裁切画面
    MiniViewData *miniView = getMiniView(stream_code);
    int scaleWidth = (FRAME_WIDTH * miniView->viewRate);
    int scaleHeight = (FRAME_WIDTH / (miniView->width * 1.0 / miniView->height) * miniView->viewRate);
    //涉及到 YUV 4:2:0 格式的图像时,宽度和高度通常需要是 2 的倍数
    if(scaleWidth % 2 == 1){
        scaleWidth+=1;
    }
    if(scaleHeight % 2 == 1){
        scaleHeight+=1;
    }
    if(scaleWidth > FRAME_WIDTH){
        scaleWidth = FRAME_WIDTH;
    }
    if(scaleHeight > FRAME_HEIGHT){
        scaleHeight = FRAME_HEIGHT;
    }
    if(!miniView->scaledYuvFrame){
        miniView->scaledYuvFrame = new YUVData;
    }
    miniView->scaledYuvFrame->width = scaleWidth;
    miniView->scaledYuvFrame->height = scaleHeight;
    miniView->scaledYuvFrame->pYrate = miniView->pYrate;
    miniView->scaledYuvFrame->pXrate = miniView->pXrate;
    uint8_t *dstData[3];
    //位置
    int offsetY = (FRAME_HEIGHT - scaleHeight) * miniView->pYrate;
    int offsetX = (FRAME_WIDTH - scaleWidth) * miniView->pXrate;
    cutYUV(yuvData,FRAME_WIDTH,FRAME_HEIGHT,offsetX,offsetY,dstData,scaleWidth,scaleHeight);
    LOGE("index:%d x:%d y:%d %dx%d view:%dx%d",index,offsetX,offsetY,scaleWidth,scaleHeight,miniView->width,miniView->height);
    copyYUV(dstData,scaleWidth,scaleHeight,miniView->scaledYuvFrame->yuvData);
    addBlackBorder(miniView->scaledYuvFrame->yuvData,scaleWidth,scaleHeight,3);
    //源数据没用了
    delete yuvData[0];
    delete yuvData[1];
    delete yuvData[2];
    //******转为rgba
    int size = scaleWidth * scaleHeight * 4;
    uint8_t *rgba_data = yuvToRGBA(dstData,scaleWidth,scaleHeight);
    //yuv数据没用了
    delete dstData[0];
    delete dstData[1];
    delete dstData[2];
    //转为java字节数组
    jbyteArray java_array = env->NewByteArray(size);
    env->SetByteArrayRegion(java_array, 0, size, (jbyte *)rgba_data);
    delete[] rgba_data;
    return java_array;
}
app/src/main/cpp/pusher/live_pusher.cpp
@@ -110,6 +110,11 @@
                }
                pusher->pushFailedCount++;
            }
            if(!RTMP_IsConnected(pusher->pRtmp)){
                LOGE("RTMP pusher 网络断连");
                pusher->isLive = -1;
                pusher->pusherCall->onConnectFailed(1001);
            }
        }else{
            //LOGI("RTMP 发给 m_nBodySize:%d 耗时:%d",packet->m_nBodySize,getCurrentTimestamp() - t);
            pusher->pushFailedCount = 0;
@@ -172,16 +177,10 @@
    if(isLive != 1){
        return;
    }
    if(!RTMP_IsConnected(pRtmp)){
        LOGE("RTMP pusher 网络断连");
        isLive = -1;
        reConnect();
    }else{
        RTMPPacket *dstPacket = new RTMPPacket ;
        int result = CopyRTMPPacket(dstPacket,packet);
        //LOGE("result:%d",result);
        streamPackets.push(dstPacket);
    }
    RTMPPacket *dstPacket = new RTMPPacket ;
    int result = CopyRTMPPacket(dstPacket,packet);
    //LOGE("result:%d",result);
    streamPackets.push(dstPacket);
    //LOGI("RTMP 发给x264解码 耗时:%d",getCurrentTimestamp() - t);
}
@@ -189,15 +188,9 @@
    if( isLive != 1){
        return;
    }
    if(!RTMP_IsConnected(pRtmp)){
        LOGE("RTMP pusher 网络断连");
        isLive = -1;
        reConnect();
    }else{
        RTMPPacket *dstPacket= new RTMPPacket ;
        CopyRTMPPacket(dstPacket,packet);
        streamPackets.push(dstPacket);
    }
    RTMPPacket *dstPacket= new RTMPPacket ;
    CopyRTMPPacket(dstPacket,packet);
    streamPackets.push(dstPacket);
}
void LivePusher::releaseLive() {
app/src/main/cpp/server_global.cpp
@@ -395,15 +395,20 @@
 * @param mainData
 */
void waterYUV(uint8_t *mainData[3]) {
    waterYUV(pushMiniDatas.size(),mainData);
}
void waterYUV(int index,uint8_t *mainData[3]) {
    //LOGI("waterYUV 加水印(画中画)");
    int size = pushMiniDatas.size();
    for (int i = 0; i < pushMiniDatas.size(); ++i) {
    for (int i = 0; i < index; ++i) {
        MiniViewData *pData = pushMiniDatas[i];
        if(pData->streamCode == mainStreamCode){
            continue;
        }
        if(!pData->videoOn){
            continue;
        }
        if(pData->streamType == 6){
            jvmMainCall->drawText(pData->streamCode,mainData);
        }
        if(!pData->scaledYuvFrame && pData->scaledYuvFrames.size() == 0 ){
            //LOGE("小窗丢帧1");
@@ -428,10 +433,10 @@
        YUVData *scaledYuvFrame = pData->scaledYuvFrame;
        //位置
        int offsetY = (mainHeight - scaledYuvFrame->height) * scaledYuvFrame->pYrate;
        int offsetX = (mainWidth - scaledYuvFrame->width) * scaledYuvFrame->pXrate;
        int offsetY = (mainHeight - scaledYuvFrame->height) * (pData->streamType == 6 ? pData->pYrate : scaledYuvFrame->pYrate);
        int offsetX = (mainWidth - scaledYuvFrame->width) * (pData->streamType == 6 ? pData->pXrate : scaledYuvFrame->pXrate);
        //LOGI("waterYUV %dx%d x:%d,y:%d  x:%f,y:%f [0]:%d",pData->scaleWidth,miniHeight,pData->pYrate,pData->pXrate,offsetX,offsetY,pData->scaledYUVData[0]);
        LOGI("waterYUV %dx%d x:%d,y:%d  x:%d,y:%d ",scaledYuvFrame->width,scaledYuvFrame->height,pData->pYrate,pData->pXrate,offsetX,offsetY);
        // 边界检查,确保不会越界
        if (offsetX + scaledYuvFrame->width > mainWidth ) {
@@ -456,17 +461,14 @@
        for (int y = 0; y < scaledYuvFrame->height / 2; y++) {
            uint8_t* main_u_row = mainData[1] + ((offsetY / 2) + y) * (mainWidth / 2) + (offsetX / 2);
            uint8_t* mini_u_row = scaledYuvFrame->yuvData[1] + y * (scaledYuvFrame->width / 2);
            memcpy(main_u_row, mini_u_row, scaledYuvFrame->width / 2);
        }
        // 处理 V 平面
        for (int y = 0; y < scaledYuvFrame->height / 2; y++) {
            uint8_t* main_v_row = mainData[2] + ((offsetY / 2) + y) * (mainWidth / 2) + (offsetX / 2);
            uint8_t* mini_v_row = scaledYuvFrame->yuvData[2] + y * (scaledYuvFrame->width / 2);
            memcpy(main_v_row, mini_v_row, scaledYuvFrame->width / 2);
        }
    }
}
@@ -529,7 +531,7 @@
    if(!miniView){
        return;
    }
    if(miniView->videoOn || miniView->streamCode == mainStreamCode){
    if(miniView->videoOn || miniView->streamCode == mainStreamCode || miniView->streamType == 6){
        pthread_mutex_lock(&pushVideoMutex);
        YUVData *yuvFrame = new YUVData();
        yuvFrame->width = miniView->width;
app/src/main/cpp/server_global.h
@@ -45,10 +45,13 @@
void scaleYUV(uint8_t *yuvData[3],int width,int height,int dstWidth,int dstHeight,uint8_t *dstData[3]);
void rotate_yuv(uint8_t* srcData[3], int width, int height,int angle,uint8_t* dstData[3],int dstWidth,int dstHeight);
void cutYUV(uint8_t *yuvData[3],int width,int height,float positionRate,uint8_t *dstData[]);
void cutYUV(uint8_t *yuvData[3],int width,int height,int cropX,int cropY,uint8_t *dstData[],int dstWidth,int dstHeight);
void copyYUV(uint8_t *yuvData[3],int width,int height, uint8_t *dstData[3]);
void pushYUV(int streamCode, uint8_t *yuvData[3]);
void pushYUYV(int streamCode,jbyte *data);
void waterYUV(uint8_t *mainData[3]);
void waterYUV(int index,uint8_t *mainData[3]);
void getPushYUV(int index,uint8_t *mainData[3]);
uint8_t* yuvToRGBA(uint8_t *yuvData[3],int width,int height);
void pushRGBA(int streamCode,uint8_t *rgbaData);
void pushNV21(int streamCode, jbyte *data);
app/src/main/java/com/runt/live/enum/StreamType.kt
@@ -7,5 +7,5 @@
 */
enum class StreamType(var value : Int) {
    NONE(-1),CAMERA(0),RTMP(1),VIDEO(2) ,AUDIO(3) ,SYSTEM(4) //视频文件、相机、音频文件,RTMP推流,本地系统
    ,UVC(5)
    ,UVC(5),TEXT(6)
}
app/src/main/java/com/runt/live/media/CameraHelper.kt
@@ -3,15 +3,12 @@
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ImageFormat
import android.graphics.Point
import android.graphics.Rect
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.util.Log
import android.util.Size
import android.view.SurfaceHolder
import androidx.annotation.OptIn
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.Camera
@@ -170,7 +167,7 @@
                if(OpenApplication.getApplication().isInfront()){
                    mStreamWindow?.surfaceHolder?.let {
                        //Log.i(TAG , "渲染: ${it.surfaceFrame.width()} ${it.surfaceFrame.height()}  ${it.hashCode()}")
                        cavansSurface(it,imageData.bitmap);
                        BitmapUtils.instance!!.cavansSurface(it,imageData.bitmap);
                    }
                    imageData.bitmap.recycle();
                }
@@ -210,24 +207,6 @@
        //preview.setSurfaceProvider(mStreamWindow.surfaceHolder!!)
        camera = cameraProvider.bindToLifecycle(this , selector , preview , imageCapture , imageAnalysis)
        mLifecycle.currentState = Lifecycle.State.RESUMED
    }
    fun cavansSurface(surfaceHolder : SurfaceHolder,bitmap : Bitmap){
        var canvas : Canvas? = null;
        try {
            canvas = surfaceHolder.lockCanvas()
            if (canvas != null) {
                var src = Rect(0,0,bitmap.width,bitmap.height)
                var des = Rect(0,0,surfaceHolder.surfaceFrame.width(),surfaceHolder.surfaceFrame.height())
                canvas.drawBitmap(bitmap,src,des,null)
            }
        } catch (e : Exception) {
            Log.e("Surface" , "绘制失败:" + e.message)
        } finally {
            if (canvas != null) {
                surfaceHolder.unlockCanvasAndPost(canvas)
            }
        }
    }
    interface OnFrameUpdateListener{
app/src/main/java/com/runt/live/media/ScreenHelper.kt
@@ -12,7 +12,6 @@
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.SurfaceHolder
import com.runt.live.cpp.LiveMiniView
import com.runt.live.cpp.LiveMiniView.mainStreamCode
import com.runt.live.data.StreamWindow
@@ -164,15 +163,7 @@
        }
        //Log.i("22222" , "swapRBMultiThread: ${numThreads}")
    }
    private fun cavansSurface(surfaceHolder : SurfaceHolder ,bitmap : Bitmap){
        var c = surfaceHolder.lockCanvas()
        if(c != null){
            var src = Rect(0,0,bitmap.width,bitmap.height)
            var des = Rect(0,0,surfaceHolder.surfaceFrame.width(),surfaceHolder.surfaceFrame.height())
            c.drawBitmap(bitmap,src,des,null)
            surfaceHolder.unlockCanvasAndPost(c)
        }
    }
    private fun printFirstRowRGBA(rgbaData : ByteArray , width : Int) { // 每行的字节数(宽度 × 每像素字节数)
        val rowBytes = width * 4
app/src/main/java/com/runt/live/media/UVCHelper.kt
@@ -16,14 +16,14 @@
import com.runt.live.data.UVCMonitor
import com.runt.live.enum.ErrorCode
import com.runt.live.enum.LiveState
import com.runt.live.ui.stream.LiveLayoutView.Companion.uvcList
import com.runt.live.ui.stream.LiveLayoutView
import java.util.Date
import kotlin.concurrent.thread
/**
 * @author Runt(qingingrunt2010@qq.com)
 * @purpose
 * @purpose usb相机,视频采集卡等
 * @date 3/6/25
 */
class UVCHelper : USBHelper {
@@ -70,6 +70,7 @@
                    }
                }
                monitor.streamWindow.sizeState.value = Point(camera.previewSize.width,camera.previewSize.height )
                monitor.streamWindow.hasVideoState.value = true;
                monitor.streamWindow.listener?.onStarted?.invoke()
                if (monitor.streamWindow.surfaceHolder != null) {
                    camera.setPreviewDisplay(monitor.streamWindow.surfaceHolder!!.surface)
@@ -140,16 +141,16 @@
    override fun onUsbRgister(intent : Intent) {
        for ( device in usbManager!!.deviceList.values){
            if(hasCamera(device) && !uvcList.value.contains(device)){
                uvcList.value+=device;
            if(hasCamera(device) && ! LiveLayoutView.usbDevices.value.contains(device)){
                LiveLayoutView.usbDevices.value+=device;
            }
        }
    }
    override fun onDeviceOut(device : UsbDevice) {
        Log.i(TAG , "onReceive: 外设已经移除")
        if(uvcList.value.contains(device)){
            uvcList.value-=device;
        if(LiveLayoutView.usbDevices.value.contains(device)){
            LiveLayoutView.usbDevices.value-=device;
        }
    }
@@ -162,8 +163,8 @@
            Log.d(TAG , "This device is a USB Camera.")
            //判断外设
            Log.i(TAG , "onReceive: 外设连接")
            if(!uvcList.value.contains(device)){
                uvcList.value+=device;
            if(! LiveLayoutView.usbDevices.value.contains(device)){
                LiveLayoutView.usbDevices.value+=device;
            }
        }
    }
app/src/main/java/com/runt/live/ui/stream/LiveActivity.kt
@@ -156,10 +156,13 @@
                        startCamera()
                    }
                    StreamType.VIDEO ->{
                        chooseFile()
                        chooseFile(it.streamType)
                    }
                    StreamType.UVC ->{
                        openUVC(it,it.remark!!)
                    }
                    StreamType.TEXT->{
                        mViewModel!!.openText(it);
                    }
                    StreamType.SYSTEM ->{
                        requestPermissions(Manifest.permission.POST_NOTIFICATIONS,{
@@ -196,6 +199,9 @@
                    }
                    StreamType.AUDIO,StreamType.VIDEO->{
                        mViewModel!!.closePlayer(it.id)
                    }
                    StreamType.TEXT->{
                    }
                    StreamType.SYSTEM->{
                        if(sysRecorderService != null){
@@ -309,11 +315,11 @@
        })
    }
    fun chooseFile(){
    fun chooseFile(type:StreamType){
        var permissions  = "";
        //android 13 权限申请细化类型
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            when(mStreamWindow!!.streamType){
            when(type){
                StreamType.VIDEO ->{
                    permissions = Manifest.permission.READ_MEDIA_VIDEO ;
                    //android 14 选择文件授权
@@ -337,7 +343,7 @@
                    // intent.action = Intent.ACTION_GET_CONTENT
                    //intent.setType("image/*")
                    when(mStreamWindow!!.streamType){
                    when(type){
                        StreamType.VIDEO ->{
                            intent.action = MediaStore.ACTION_PICK_IMAGES
                            intent.setType("video/*") // 设置文件类型,可以更具体如"application/pdf"
@@ -426,6 +432,7 @@
        }
        uvcHelper?.unRegister();
    }
    fun getMemoryInfo(context : Context):String? {
        val memoryInfo = Debug.MemoryInfo()
        Debug.getMemoryInfo(memoryInfo) // 以 KB 为单位
app/src/main/java/com/runt/live/ui/stream/LiveLayoutView.kt
@@ -2,6 +2,7 @@
import android.content.Context
import android.content.res.Configuration
import android.graphics.PixelFormat
import android.graphics.Point
import android.graphics.PointF
import android.hardware.usb.UsbDevice
@@ -88,6 +89,7 @@
import androidx.compose.ui.window.Dialog
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.lifecycle.viewModelScope
import com.runt.live.R
import com.runt.live.cpp.LiveMiniView
import com.runt.live.data.LiveAddress
import com.runt.live.data.LiveStreams
@@ -121,8 +123,6 @@
    var liveAdressesState = mutableStateListOf<LiveAddress>()
    var subStreamsState = mutableStateOf(listOf<StreamWindow>())
    var liveStreamsState = mutableStateOf(LiveStreams(subStreamsState))
    var showMainBorder  = mutableStateOf(false);
    var mainStreamState = mutableStateOf(0)
@@ -142,14 +142,22 @@
    //新窗口
    var openSurfaceClick = MutableSharedFlow<StreamWindow>();
    fun pxToDp(pix:Int):Dp{
        var dpV = pix/mViewModel.getActivity().resources.displayMetrics.density;
        return dpV.dp;
    }
    fun sp2px(spValue: Float): Int {
        val fontScale = mViewModel.getActivity().resources.displayMetrics.density
        return (spValue * fontScale + 0.5f).toInt()
    }
    companion object {
        var uvcList = mutableStateOf(listOf<UsbDevice>())
        var usbDevices = mutableStateOf(listOf<UsbDevice>())
        var subStreamsState = mutableStateOf(listOf<StreamWindow>())
        var liveStreamsState = mutableStateOf(LiveStreams(subStreamsState))
    }
    fun onCloseClick(streamWindow : StreamWindow){
@@ -417,6 +425,10 @@
                    streamWindow.surfaceView = this;
                    streamWindow.surfaceHolder = holder;
                    streamWindow.listener?.onSurfaceUpdate?.let { it(holder.surface) }
                    if(streamWindow.streamType == StreamType.TEXT){
                        holder.setFormat(PixelFormat.TRANSLUCENT) // 透明像素格式
                        setBackgroundColor(context.resources.getColor(R.color.transparent))     // 设置背景透明
                    }
                    holder.addCallback(streamWindow.surfaceCallBack)
                }
@@ -452,7 +464,7 @@
                    streamWindow.surfaceView?.setZOrderMediaOverlay(true)
                    subStreamsState.value = value;
                    for (stream in subStreamsState.value) {
                        mViewModel.updateMiniView(subStreamsState.value.indexOf(streamWindow) , streamWindow)
                        mViewModel.updateMiniView(subStreamsState.value.indexOf(streamWindow), streamWindow)
                    }
                }
                .align(alignment = Alignment.TopStart),
@@ -987,7 +999,8 @@
                    }
                }
            }
            val openInputDialog = remember { mutableStateOf(false) }
            val rtmpInputDialog = remember { mutableStateOf(false) }
            val textInputDialog = remember { mutableStateOf(false) }
            val openScreenDialog = remember { mutableStateOf(false) }
            Row {
                if(!liveStreamsState.value.hasCamera()){
@@ -999,7 +1012,7 @@
                    }
                }
                Button(onClick = {
                    openInputDialog.value = true
                    rtmpInputDialog.value = true
                }, modifier = Modifier.padding(start = 10.dp), contentPadding = PaddingValues(0.dp)) {
                    Text(text = "RTMP")
                }
@@ -1014,16 +1027,21 @@
                    Text(text = "音频")
                }*/
                // USB Video Class (UVC)  视频采集卡
                if(uvcList.value.size > 0){
                if(usbDevices.value.size > 0){
                    Button(onClick = {
                        var streamWindow = StreamWindow(streamType = StreamType.UVC);
                        //默认没有音频
                        streamWindow.hasAudioState.value = false;
                        streamWindow.remark = uvcList.value[0].deviceName
                        streamWindow.remark = usbDevices.value[0].deviceName
                        newSurfaceClick(streamWindow)
                    }, modifier = Modifier.padding(start = 10.dp), contentPadding = PaddingValues(0.dp)) {
                        Text(text = "UVC")
                    }
                }
                Button(onClick = {
                    textInputDialog.value = true
                }, modifier = Modifier.padding(start = 10.dp), contentPadding = PaddingValues(0.dp)) {
                    Text(text = "文本")
                }
                if(!liveStreamsState.value.hasSystem()) {
                    Button(onClick = {
@@ -1034,12 +1052,24 @@
                }
            }
            when{
                openInputDialog.value->{
                    InputDialog(onDismissRequest = { openInputDialog.value = false } , onConfirmRequest = {
                        openInputDialog.value = false
                rtmpInputDialog.value->{
                    var url =  (mViewModel.getActivity() as LiveActivity ).getStringProjectPrefrence("pullAddr","rtmp://192.168.28.79/live");
                    InputDialog(url,StreamType.RTMP,onDismissRequest = { rtmpInputDialog.value = false } , onConfirmRequest = {
                        rtmpInputDialog.value = false
                        (mViewModel.getActivity() as LiveActivity ) .putStringProjectPrefrence("pullAddr",it)
                        var streamWindow = StreamWindow(streamType = StreamType.RTMP, remark = it);
                        streamWindow.hasVideoState.value = false;
                        streamWindow.hasAudioState.value = false;
                        newSurfaceClick(streamWindow)
                    })
                }
                textInputDialog.value->{
                    var url =  (mViewModel.getActivity() as LiveActivity ).getStringProjectPrefrence("live_text","");
                    InputDialog(url,StreamType.TEXT,onDismissRequest = { textInputDialog.value = false } , onConfirmRequest = {
                        textInputDialog.value = false
                        (mViewModel.getActivity() as LiveActivity ) .putStringProjectPrefrence("live_text",it)
                        var streamWindow = StreamWindow(streamType = StreamType.TEXT, remark = it);
                        streamWindow.hasVideoState.value = true;
                        streamWindow.hasAudioState.value = false;
                        newSurfaceClick(streamWindow)
                    })
@@ -1164,19 +1194,36 @@
    }
    @Composable
    fun InputDialog(onDismissRequest:()->Unit,onConfirmRequest:(String)->Unit){
        var url =  (mViewModel.getActivity() as LiveActivity ).getStringProjectPrefrence("pullAddr","rtmp://192.168.28.79/live");
    fun InputDialog(url:String,streamType : StreamType,onDismissRequest:()->Unit,onConfirmRequest:(String)->Unit){
        var text by remember {
            mutableStateOf(url)
        }
        var modifier : Modifier;
        var keyboardType : KeyboardType;
        if(streamType == StreamType.RTMP){
            modifier = Modifier
                .width(260.dp)
                .height(50.dp)
                .padding(0.dp)
            keyboardType = KeyboardType.Uri;
        }else{
            modifier = Modifier
                .width(260.dp)
                .padding(0.dp)
            keyboardType = KeyboardType.Text;
        }
        AlertDialog(onDismissRequest = { onDismissRequest() } ,
            confirmButton = {
                TextButton(onClick = {
                    val pattern = """rtmp://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]""".toRegex()
                    if(text.length > 7 && pattern.matches(text)){
                        onConfirmRequest(text)
                    if(streamType == StreamType.RTMP){
                        val pattern = """rtmp://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]""".toRegex()
                        if(text.length > 7 && pattern.matches(text)){
                            onConfirmRequest(text)
                        }else{
                            Toast.makeText(mViewModel.getActivity(),"地址格式错误",Toast.LENGTH_SHORT).show();
                        }
                    }else{
                        Toast.makeText(mViewModel.getActivity(),"地址格式错误",Toast.LENGTH_SHORT).show();
                        onConfirmRequest(text)
                    }
                }) {
                    Text(text = "确认")
@@ -1187,22 +1234,27 @@
                }
            },
            title = {
                Text(text = "请输入直播地址", fontSize = 16.sp)
                if(streamType == StreamType.RTMP) {
                    Text(text = "请输入直播地址" , fontSize = 16.sp)
                }else{
                    Text(text = "请输入要展示的文本" , fontSize = 16.sp)
                }
            },
            text = {
                TextField(value = text , onValueChange = {
                    //val pattern = """(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]""".toRegex()
                    text = it;
                },singleLine = true,
                    modifier = Modifier
                        .width(260.dp)
                        .height(50.dp)
                        .padding(0.dp),
                },singleLine = streamType == StreamType.RTMP,
                    modifier = modifier,
                    keyboardOptions = KeyboardOptions(
                        keyboardType = KeyboardType.Uri
                        keyboardType = keyboardType
                    ) ,
                    placeholder = {
                        Text(text = "请输入直播地址", fontSize = 14.sp)
                        if(streamType == StreamType.RTMP) {
                            Text(text = "请输入直播地址" , fontSize = 14.sp)
                        }else{
                            Text(text = "请输入要展示的文本" , fontSize = 14.sp)
                        }
                    }
                    , textStyle = TextStyle(fontSize = 14.sp, color = Color.Black))
            })
app/src/main/java/com/runt/live/ui/stream/LiveViewModel.kt
@@ -1,13 +1,27 @@
package com.runt.live.ui.stream
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Point
import android.graphics.Rect
import android.util.Log
import android.view.SurfaceHolder
import com.runt.live.R
import com.runt.live.cpp.LiveMiniView
import com.runt.live.cpp.LiveMiniView.FRAME_HEIGHT
import com.runt.live.cpp.LiveMiniView.FRAME_WIDTH
import com.runt.live.data.StreamWindow
import com.runt.live.enum.LiveState
import com.runt.live.enum.StreamType
import com.runt.live.native.LivePuller
import com.runt.live.native.LivePusher
import com.runt.live.native.MediaPlayer
import com.runt.live.ui.stream.LiveLayoutView.Companion.subStreamsState
import com.runt.live.util.BitmapUtils
import com.runt.open.mvi.base.model.BaseViewModel
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentSkipListMap
import kotlin.concurrent.thread
/**
@@ -37,8 +51,6 @@
        players.put(streamWindow.id,player)
        player.load(filePath)
    }
    fun closePlayer(streamCode : Int){
        players.get(streamCode)?.let {
@@ -94,6 +106,7 @@
        }
        pushers.remove(code);
    }
    fun closePusher(url : String){
        var code = getPusherCode(url);
        if(code > -1){
@@ -138,6 +151,85 @@
        LiveMiniView.native_remove_mini_view(streamCode)
    }
    fun updateText(streamWindow : StreamWindow){
        thread {
            var bytes = LiveMiniView.native_get_cut_frame(subStreamsState.value.indexOf(streamWindow),streamWindow.id)
            var bitmap = BitmapUtils.instance!!.textToBitmap(streamWindow.remark!!,130f,getActivity().resources.getColor(R.color.white)!!,getActivity().resources.getColor(R.color.transparent)!!)
            var cutwidth : Int = (FRAME_WIDTH * streamWindow.viewRateState.value).toInt()
            var cutHeight : Int = (FRAME_WIDTH / ( streamWindow.sizeState.value.x * 1.0 /  streamWindow.sizeState.value.y ) * streamWindow.viewRateState.value).toInt()
            //涉及到 YUV 4:2:0 格式的图像时,宽度和高度通常需要是 2 的倍数
            if (cutwidth % 2 == 1) {
                cutwidth += 1
            }
            if (cutHeight % 2 == 1) {
                cutHeight += 1
            }
            if (cutwidth > FRAME_WIDTH) {
                cutwidth = FRAME_WIDTH
            }
            if (cutHeight > FRAME_HEIGHT) {
                cutHeight = FRAME_HEIGHT
            }
            val cutBitmap = Bitmap.createBitmap(cutwidth, cutHeight, Bitmap.Config.ARGB_8888)
            Log.e(TAG , "updateText: ${cutwidth}x${cutHeight} ${streamWindow.sizeState.value} ${( streamWindow.sizeState.value.x * 1.0 /  streamWindow.sizeState.value.y )}", )
            val buffer = ByteBuffer.wrap(bytes)
            cutBitmap.copyPixelsFromBuffer(buffer)
            val dstBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
            val canvas = Canvas(dstBitmap)
            // 构建目标区域
            val destRect = Rect(0,0,dstBitmap.width,dstBitmap.height)
            // 绘制小图到大图
            canvas.drawBitmap(cutBitmap, null, destRect, null)
            //canvas.drawBitmap(bitmap, null, destRect, null)
            val width : Int = bitmap.getWidth()
            val height : Int = bitmap.getHeight()
            val argb = IntArray(width * height)
            dstBitmap.getPixels(argb , 0 , width , 0 , 0 , width , height)
            //LiveMiniView.native_push_nv21(streamWindow.id,BitmapUtils.instance!!.argbToNV21(argb,width,height));
        }
    }
    fun openText(streamWindow : StreamWindow){
        var bitmap = BitmapUtils.instance!!.textToBitmap(streamWindow.remark!!,130f,getActivity().resources.getColor(R.color.white)!!,getActivity().resources.getColor(R.color.transparent)!!)
        streamWindow.hasAudioState.value = false;
        streamWindow.hasVideoState.value = true;
        streamWindow.sizeState.value = Point(bitmap.width,bitmap.height);
        streamWindow.surfaceCallBack = object : SurfaceHolder.Callback {
            override fun surfaceCreated(holder : SurfaceHolder) {
                Log.w(TAG , "surfaceCreated: ${holder.hashCode()}")
            }
            override fun surfaceChanged(holder : SurfaceHolder , format : Int , width : Int , height : Int) {
                streamWindow.surfaceHolder = holder;
                streamWindow.listener?.onSurfaceUpdate?.let { it(holder.surface) }
                thread {
                    BitmapUtils.instance!!.cavansSurface(holder,bitmap);
                    val width : Int = bitmap.getWidth()
                    val height : Int = bitmap.getHeight()
                    val argb = IntArray(width * height)
                    bitmap.getPixels(argb , 0 , width , 0 , 0 , width , height)
                    LiveMiniView.native_push_nv21(streamWindow.id,BitmapUtils.instance!!.argbToNV21(argb,width,height));
                }
                //Log.w(TAG , "surfaceChanged: ${holder.hashCode()}" , )
            }
            override fun surfaceDestroyed(holder : SurfaceHolder) {
                streamWindow.surfaceHolder = null;
                streamWindow.listener?.onSurfaceUpdate?.let { it(null) }
                //Log.w(TAG , "surfaceDestroyed: ${holder.hashCode()} ${id}" , )
            }
        }
        streamWindow.listener?.onStarted?.invoke()
    }
    /**
     * 拉流
     */
app/src/main/java/com/runt/live/util/BitmapUtils.kt
@@ -3,9 +3,11 @@
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ImageFormat
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Rect
import android.media.ExifInterface
import android.media.Image
@@ -18,11 +20,13 @@
import android.renderscript.ScriptIntrinsicYuvToRGB
import android.renderscript.Type
import android.util.Log
import android.view.SurfaceHolder
import androidx.annotation.OptIn
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageProxy
import java.io.IOException
import java.nio.ByteBuffer
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
@@ -683,5 +687,95 @@
        }
    }
    fun textToBitmap(text : String , textSize : Float , textColor : Int , bgColor : Int) : Bitmap { // 创建 Paint 对象设置文本参数
        val paint = Paint(Paint.ANTI_ALIAS_FLAG)
        paint.textSize = textSize.toFloat()
        paint.color = textColor
        val fontMetrics = paint.fontMetrics
        val lineHeight : Float = fontMetrics.bottom - fontMetrics.top + 30
        // 按换行符分割文本
        val lines  = text.split("\n")
        // 计算最大宽度
        var maxWidth = 0f
        for (line in lines) {
            val width = paint.measureText(line)
            if (width > maxWidth) maxWidth = width
        }
        // 创建 Bitmap
        val bmpWidth = ceil(maxWidth.toDouble()).toInt()
        val bmpHeight = ceil((lineHeight * lines.size).toDouble()).toInt()
        val bitmap = Bitmap.createBitmap(bmpWidth , bmpHeight , Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        canvas.drawColor(bgColor) // 背景色
        // 逐行绘制文本
        var y = - fontMetrics.top
        for (line in lines) {
            canvas.drawText(line !! , 0f , y , paint)
            y += lineHeight
        }
        return bitmap
    }
    fun argbToNV21(argb : IntArray , width : Int , height : Int) : ByteArray {
        val yuv = ByteArray(width * height * 3 / 2)
        val frameSize = width * height
        var yIndex = 0
        var uvIndex = frameSize
        for (j in 0 until height) {
            for (i in 0 until width) {
                val argbIndex = j * width + i
                val color = argb[argbIndex]
                val r = (color shr 16) and 0xFF
                val g = (color shr 8) and 0xFF
                val b = color and 0xFF
                // RGB to YUV conversion
                val y = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
                val u = ((- 38 * r - 74 * g + 112 * b + 128) shr 8) + 128
                val v = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
                yuv[yIndex ++] = max(0.0 , min(255.0 , y.toDouble())).toInt().toByte()
                // 每 2x2 像素写一次 UV
                if ((j % 2 == 0) && (i % 2 == 0)) {
                    yuv[uvIndex ++] = max(0.0 , min(255.0 , v.toDouble())).toInt().toByte()
                    yuv[uvIndex ++] = max(0.0 , min(255.0 , u.toDouble())).toInt().toByte()
                }
            }
        }
        return yuv
    }
    fun cavansSurface(surfaceHolder : SurfaceHolder ,bitmap : Bitmap){
        var canvas : Canvas? = null;
        try {
            canvas = surfaceHolder.lockCanvas()
            if (canvas != null) {
                var src = Rect(0,0,bitmap.width,bitmap.height)
                var des = Rect(0,0,surfaceHolder.surfaceFrame.width(),surfaceHolder.surfaceFrame.height())
                canvas.drawBitmap(bitmap,src,des,null)
            }
        } catch (e : Exception) {
            Log.e("Surface" , "绘制失败:" + e.message)
        } finally {
            if (canvas != null) {
                surfaceHolder.unlockCanvasAndPost(canvas)
            }
        }
    }
}
app/src/main/res/values/colors.xml
@@ -7,4 +7,5 @@
    <color name="teal_700">#FF018786</color>
    <color name="black">#FF000000</color>
    <color name="white">#FFFFFFFF</color>
    <color name="transparent">#00000000</color>
</resources>