Files
2026-03-22 15:24:40 +08:00

499 lines
16 KiB
Kotlin

/**
* 自然写互动课堂智慧黑板端应用软件 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)
}
}