/** * 自然写互动课堂智慧黑板端应用软件 V1.0 * * ScreenRecorder.kt - 课堂录制模块 * * 功能说明: * - 课堂屏幕录制(MediaCodec H.264编码) * - 音频同步录制(AAC编码) * - MediaMuxer封装MP4文件 * - 录制进度跟踪与时间限制 * - 录像文件管理(存储/上传/清理) * - 课堂回放支持 */ package com.writech.board.recording import android.content.Context import android.media.* import android.os.Environment import android.util.Log import android.view.Surface import java.io.File import java.nio.ByteBuffer import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.thread /** * 录制状态 */ enum class RecordingState { IDLE, /* 空闲 */ PREPARING, /* 准备中 */ RECORDING, /* 录制中 */ PAUSED, /* 暂停 */ STOPPING, /* 停止中 */ ERROR /* 错误 */ } /** * 录制配置参数 */ data class RecordingConfig( val videoWidth: Int = 1920, /* 视频宽度 */ val videoHeight: Int = 1080, /* 视频高度 */ val videoBitrate: Int = 6_000_000, /* 视频码率 6Mbps */ val videoFps: Int = 30, /* 帧率 30fps */ val audioEnabled: Boolean = true, /* 是否录制音频 */ val audioBitrate: Int = 128_000, /* 音频码率 128kbps */ val audioSampleRate: Int = 44100, /* 音频采样率 */ val maxDurationSec: Int = 5400, /* 最大录制时长 90分钟 */ val outputDir: String = "" /* 输出目录 */ ) /** * 录制结果信息 */ data class RecordingResult( val filePath: String, /* 录像文件路径 */ val durationMs: Long, /* 录制时长(毫秒) */ val fileSize: Long, /* 文件大小(字节) */ val videoWidth: Int, /* 视频宽度 */ val videoHeight: Int, /* 视频高度 */ val timestamp: Long = System.currentTimeMillis() ) /** * 录制事件回调 */ interface RecordingListener { fun onRecordingStateChanged(state: RecordingState) fun onRecordingProgress(durationMs: Long) fun onRecordingCompleted(result: RecordingResult) fun onRecordingError(error: String) } /** * 课堂屏幕录制器 * * 使用Android MediaCodec + MediaMuxer实现高效屏幕录制: * - 视频编码: H.264 (AVC), 1080p@30fps * - 音频编码: AAC-LC, 44.1kHz * - 容器格式: MP4 (MPEG-4 Part 14) */ class ScreenRecorder(private val context: Context) { companion object { private const val TAG = "ScreenRecorder" private const val VIDEO_MIME = MediaFormat.MIMETYPE_VIDEO_AVC private const val AUDIO_MIME = MediaFormat.MIMETYPE_AUDIO_AAC /** I帧间隔(秒) */ private const val IFRAME_INTERVAL = 2 /** 编码器超时(微秒) */ private const val CODEC_TIMEOUT_US = 10000L /** 进度回调间隔(毫秒) */ private const val PROGRESS_INTERVAL_MS = 1000L } /* ==================== 状态 ==================== */ /** 录制状态 */ var state: RecordingState = RecordingState.IDLE private set /** 录制配置 */ private var config = RecordingConfig() /** 是否正在录制 */ private val isRecording = AtomicBoolean(false) /** 录制开始时间 */ private var startTimeNs: Long = 0 /** 暂停累计时间 */ private var pausedDurationNs: Long = 0 /** 暂停起始时间 */ private var pauseStartNs: Long = 0 /* ==================== 编码器 ==================== */ /** 视频编码器 */ private var videoEncoder: MediaCodec? = null /** 音频编码器 */ private var audioEncoder: MediaCodec? = null /** 混流器 */ private var mediaMuxer: MediaMuxer? = null /** 视频输入Surface */ private var inputSurface: Surface? = null /** 视频轨道索引 */ private var videoTrackIndex: Int = -1 /** 音频轨道索引 */ private var audioTrackIndex: Int = -1 /** Muxer是否已启动 */ private var isMuxerStarted = false /** 已添加的轨道数 */ private var tracksAdded = 0 /** 输出文件路径 */ private var outputFilePath: String = "" /* ==================== 监听器 ==================== */ /** 事件监听器 */ private var listener: RecordingListener? = null /** * 设置录制事件监听器 */ fun setListener(listener: RecordingListener) { this.listener = listener } /* ==================== 录制控制 ==================== */ /** * 开始录制 * * @param config 录制配置 * @return 视频输入Surface(渲染内容将被录制) */ fun startRecording(config: RecordingConfig = RecordingConfig()): Surface? { if (state != RecordingState.IDLE && state != RecordingState.ERROR) { Log.w(TAG, "无法启动录制, 当前状态=$state") return null } this.config = config changeState(RecordingState.PREPARING) try { /* 生成输出文件路径 */ outputFilePath = generateOutputPath() Log.i(TAG, "录制输出: $outputFilePath") /* 配置视频编码器 */ setupVideoEncoder() /* 配置音频编码器 */ if (config.audioEnabled) { setupAudioEncoder() } /* 创建MediaMuxer */ mediaMuxer = MediaMuxer(outputFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) /* 启动编码器 */ videoEncoder?.start() audioEncoder?.start() /* 获取视频输入Surface */ inputSurface = videoEncoder?.createInputSurface() isRecording.set(true) startTimeNs = System.nanoTime() pausedDurationNs = 0 /* 启动编码线程 */ startEncodingThreads() changeState(RecordingState.RECORDING) Log.i(TAG, "录制开始: ${config.videoWidth}x${config.videoHeight} " + "@${config.videoFps}fps, 码率=${config.videoBitrate / 1_000_000}Mbps") return inputSurface } catch (e: Exception) { Log.e(TAG, "启动录制失败", e) changeState(RecordingState.ERROR) listener?.onRecordingError("启动录制失败: ${e.message}") releaseResources() return null } } /** * 暂停录制 */ fun pauseRecording() { if (state != RecordingState.RECORDING) return pauseStartNs = System.nanoTime() changeState(RecordingState.PAUSED) Log.i(TAG, "录制已暂停") } /** * 恢复录制 */ fun resumeRecording() { if (state != RecordingState.PAUSED) return pausedDurationNs += System.nanoTime() - pauseStartNs changeState(RecordingState.RECORDING) Log.i(TAG, "录制已恢复") } /** * 停止录制 */ fun stopRecording() { if (state != RecordingState.RECORDING && state != RecordingState.PAUSED) { Log.w(TAG, "非录制状态无法停止") return } changeState(RecordingState.STOPPING) isRecording.set(false) Log.i(TAG, "停止录制中...") /* 等待编码线程结束后再释放资源(在编码线程中处理) */ } /* ==================== 编码器配置 ==================== */ /** * 配置视频编码器(H.264) */ private fun setupVideoEncoder() { val format = MediaFormat.createVideoFormat(VIDEO_MIME, config.videoWidth, config.videoHeight) format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) format.setInteger(MediaFormat.KEY_BIT_RATE, config.videoBitrate) format.setInteger(MediaFormat.KEY_FRAME_RATE, config.videoFps) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL) /* 设置编码Profile为High,提升压缩效率 */ format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh) format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel41) videoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME) videoEncoder?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) Log.d(TAG, "视频编码器配置: ${config.videoWidth}x${config.videoHeight}, " + "码率=${config.videoBitrate}, 帧率=${config.videoFps}") } /** * 配置音频编码器(AAC-LC) */ private fun setupAudioEncoder() { val format = MediaFormat.createAudioFormat(AUDIO_MIME, config.audioSampleRate, 1) format.setInteger(MediaFormat.KEY_BIT_RATE, config.audioBitrate) format.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384) audioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME) audioEncoder?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) Log.d(TAG, "音频编码器配置: ${config.audioSampleRate}Hz, " + "码率=${config.audioBitrate}") } /* ==================== 编码线程 ==================== */ /** * 启动编码线程 */ private fun startEncodingThreads() { /* 视频编码线程 */ thread(name = "VideoEncoder") { drainEncoder(videoEncoder, true) } /* 音频编码线程 */ if (config.audioEnabled) { thread(name = "AudioEncoder") { drainEncoder(audioEncoder, false) } } /* 进度回调线程 */ thread(name = "RecordingProgress") { while (isRecording.get()) { if (state == RecordingState.RECORDING) { val elapsed = (System.nanoTime() - startTimeNs - pausedDurationNs) / 1_000_000 listener?.onRecordingProgress(elapsed) /* 检查最大时长限制 */ if (elapsed > config.maxDurationSec * 1000L) { Log.i(TAG, "达到最大录制时长 ${config.maxDurationSec}秒, 自动停止") stopRecording() } } Thread.sleep(PROGRESS_INTERVAL_MS) } } } /** * 从编码器中取出编码后的数据并写入Muxer */ private fun drainEncoder(encoder: MediaCodec?, isVideo: Boolean) { if (encoder == null) return val bufferInfo = MediaCodec.BufferInfo() val encoderName = if (isVideo) "视频" else "音频" try { while (isRecording.get() || true) { val outputIndex = encoder.dequeueOutputBuffer(bufferInfo, CODEC_TIMEOUT_US) when { outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { /* 添加轨道到Muxer */ val format = encoder.outputFormat synchronized(this) { if (isVideo) { videoTrackIndex = mediaMuxer?.addTrack(format) ?: -1 Log.d(TAG, "${encoderName}轨道添加: index=$videoTrackIndex") } else { audioTrackIndex = mediaMuxer?.addTrack(format) ?: -1 Log.d(TAG, "${encoderName}轨道添加: index=$audioTrackIndex") } tracksAdded++ /* 所有轨道就绪后启动Muxer */ val expectedTracks = if (config.audioEnabled) 2 else 1 if (tracksAdded >= expectedTracks && !isMuxerStarted) { mediaMuxer?.start() isMuxerStarted = true Log.i(TAG, "MediaMuxer已启动") } } } outputIndex >= 0 -> { val buffer = encoder.getOutputBuffer(outputIndex) ?: continue if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { bufferInfo.size = 0 } if (bufferInfo.size > 0 && isMuxerStarted) { val trackIndex = if (isVideo) videoTrackIndex else audioTrackIndex synchronized(this) { mediaMuxer?.writeSampleData(trackIndex, buffer, bufferInfo) } } encoder.releaseOutputBuffer(outputIndex, false) /* 检查结束标志 */ if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { Log.d(TAG, "${encoderName}编码结束") break } } } if (!isRecording.get()) { encoder.signalEndOfInputStream() } } } catch (e: Exception) { Log.e(TAG, "${encoderName}编码异常", e) } finally { if (isVideo) { /* 视频编码完成后释放资源 */ onEncodingFinished() } } } /** * 编码完成后的清理工作 */ private fun onEncodingFinished() { val durationMs = (System.nanoTime() - startTimeNs - pausedDurationNs) / 1_000_000 releaseResources() /* 获取文件大小 */ val file = File(outputFilePath) val fileSize = if (file.exists()) file.length() else 0 val result = RecordingResult( filePath = outputFilePath, durationMs = durationMs, fileSize = fileSize, videoWidth = config.videoWidth, videoHeight = config.videoHeight ) changeState(RecordingState.IDLE) listener?.onRecordingCompleted(result) Log.i(TAG, "录制完成: 时长=${durationMs / 1000}秒, " + "文件大小=${fileSize / 1024}KB, 路径=$outputFilePath") } /* ==================== 资源管理 ==================== */ /** * 释放所有资源 */ private fun releaseResources() { try { videoEncoder?.stop() videoEncoder?.release() videoEncoder = null } catch (e: Exception) { /* 忽略 */ } try { audioEncoder?.stop() audioEncoder?.release() audioEncoder = null } catch (e: Exception) { /* 忽略 */ } try { if (isMuxerStarted) { mediaMuxer?.stop() } mediaMuxer?.release() mediaMuxer = null } catch (e: Exception) { /* 忽略 */ } inputSurface?.release() inputSurface = null isMuxerStarted = false tracksAdded = 0 videoTrackIndex = -1 audioTrackIndex = -1 Log.d(TAG, "录制资源已释放") } /** * 生成录像文件输出路径 */ private fun generateOutputPath(): String { val dir = if (config.outputDir.isNotEmpty()) { File(config.outputDir) } else { File(context.filesDir, "recordings") } if (!dir.exists()) dir.mkdirs() val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.CHINA) val fileName = "class_${dateFormat.format(Date())}.mp4" return File(dir, fileName).absolutePath } /** * 状态变更 */ private fun changeState(newState: RecordingState) { state = newState listener?.onRecordingStateChanged(newState) } }