499 lines
16 KiB
Kotlin
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)
|
|
}
|
|
}
|