正在显示
2 个修改的文件
包含
167 行增加
和
70 行删除
| 1 | <component name="InspectionProjectProfileManager"> | 1 | <component name="InspectionProjectProfileManager"> |
| 2 | <profile version="1.0"> | 2 | <profile version="1.0"> |
| 3 | <option name="myName" value="Project Default" /> | 3 | <option name="myName" value="Project Default" /> |
| 4 | + <inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> | ||
| 5 | + <option name="composableFile" value="true" /> | ||
| 6 | + </inspection_tool> | ||
| 7 | + <inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true"> | ||
| 8 | + <option name="composableFile" value="true" /> | ||
| 9 | + </inspection_tool> | ||
| 10 | + <inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> | ||
| 11 | + <option name="composableFile" value="true" /> | ||
| 12 | + </inspection_tool> | ||
| 13 | + <inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true"> | ||
| 14 | + <option name="composableFile" value="true" /> | ||
| 15 | + </inspection_tool> | ||
| 16 | + <inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> | ||
| 17 | + <option name="composableFile" value="true" /> | ||
| 18 | + </inspection_tool> | ||
| 19 | + <inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true"> | ||
| 20 | + <option name="composableFile" value="true" /> | ||
| 21 | + </inspection_tool> | ||
| 22 | + <inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> | ||
| 23 | + <option name="composableFile" value="true" /> | ||
| 24 | + </inspection_tool> | ||
| 25 | + <inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true"> | ||
| 26 | + <option name="composableFile" value="true" /> | ||
| 27 | + </inspection_tool> | ||
| 4 | <inspection_tool class="MemberVisibilityCanBePrivate" enabled="true" level="WEAK WARNING" enabled_by_default="true"> | 28 | <inspection_tool class="MemberVisibilityCanBePrivate" enabled="true" level="WEAK WARNING" enabled_by_default="true"> |
| 5 | <scope name="Library Projects" level="WEAK WARNING" enabled="false" /> | 29 | <scope name="Library Projects" level="WEAK WARNING" enabled="false" /> |
| 6 | </inspection_tool> | 30 | </inspection_tool> |
| @@ -12,6 +36,9 @@ | @@ -12,6 +36,9 @@ | ||
| 12 | <option name="composableFile" value="true" /> | 36 | <option name="composableFile" value="true" /> |
| 13 | <option name="previewFile" value="true" /> | 37 | <option name="previewFile" value="true" /> |
| 14 | </inspection_tool> | 38 | </inspection_tool> |
| 39 | + <inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true"> | ||
| 40 | + <option name="composableFile" value="true" /> | ||
| 41 | + </inspection_tool> | ||
| 15 | <inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> | 42 | <inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> |
| 16 | <option name="composableFile" value="true" /> | 43 | <option name="composableFile" value="true" /> |
| 17 | <option name="previewFile" value="true" /> | 44 | <option name="previewFile" value="true" /> |
| @@ -36,6 +63,9 @@ | @@ -36,6 +63,9 @@ | ||
| 36 | <option name="composableFile" value="true" /> | 63 | <option name="composableFile" value="true" /> |
| 37 | <option name="previewFile" value="true" /> | 64 | <option name="previewFile" value="true" /> |
| 38 | </inspection_tool> | 65 | </inspection_tool> |
| 66 | + <inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true"> | ||
| 67 | + <option name="composableFile" value="true" /> | ||
| 68 | + </inspection_tool> | ||
| 39 | <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> | 69 | <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> |
| 40 | <option name="composableFile" value="true" /> | 70 | <option name="composableFile" value="true" /> |
| 41 | <option name="previewFile" value="true" /> | 71 | <option name="previewFile" value="true" /> |
| @@ -16,6 +16,7 @@ | @@ -16,6 +16,7 @@ | ||
| 16 | 16 | ||
| 17 | package io.livekit.android.sample.record | 17 | package io.livekit.android.sample.record |
| 18 | 18 | ||
| 19 | +import android.Manifest | ||
| 19 | import android.os.Bundle | 20 | import android.os.Bundle |
| 20 | import android.os.Environment | 21 | import android.os.Environment |
| 21 | import androidx.activity.ComponentActivity | 22 | import androidx.activity.ComponentActivity |
| @@ -30,59 +31,64 @@ import androidx.compose.runtime.getValue | @@ -30,59 +31,64 @@ import androidx.compose.runtime.getValue | ||
| 30 | import androidx.compose.runtime.livedata.observeAsState | 31 | import androidx.compose.runtime.livedata.observeAsState |
| 31 | import androidx.compose.ui.Modifier | 32 | import androidx.compose.ui.Modifier |
| 32 | import androidx.lifecycle.MutableLiveData | 33 | import androidx.lifecycle.MutableLiveData |
| 33 | -import androidx.lifecycle.lifecycleScope | ||
| 34 | -import io.livekit.android.AudioOptions | ||
| 35 | -import io.livekit.android.LiveKit | ||
| 36 | -import io.livekit.android.LiveKitOverrides | ||
| 37 | -import io.livekit.android.room.Room | ||
| 38 | -import io.livekit.android.room.track.LocalVideoTrack | ||
| 39 | -import io.livekit.android.room.track.Track | 34 | +import androidx.core.app.ActivityCompat |
| 40 | import io.livekit.android.sample.record.ui.theme.LivekitandroidTheme | 35 | import io.livekit.android.sample.record.ui.theme.LivekitandroidTheme |
| 41 | import io.livekit.android.sample.util.requestNeededPermissions | 36 | import io.livekit.android.sample.util.requestNeededPermissions |
| 42 | -import kotlinx.coroutines.launch | 37 | +import livekit.org.webrtc.AudioSource |
| 38 | +import livekit.org.webrtc.AudioTrack | ||
| 39 | +import livekit.org.webrtc.Camera2Enumerator | ||
| 40 | +import livekit.org.webrtc.CameraVideoCapturer | ||
| 41 | +import livekit.org.webrtc.DefaultVideoDecoderFactory | ||
| 42 | +import livekit.org.webrtc.DefaultVideoEncoderFactory | ||
| 43 | import livekit.org.webrtc.EglBase | 43 | import livekit.org.webrtc.EglBase |
| 44 | +import livekit.org.webrtc.audio.JavaAudioDeviceModule | ||
| 45 | +import livekit.org.webrtc.MediaConstraints | ||
| 46 | +import livekit.org.webrtc.PeerConnectionFactory | ||
| 47 | +import livekit.org.webrtc.SurfaceTextureHelper | ||
| 48 | +import livekit.org.webrtc.VideoSource | ||
| 49 | +import livekit.org.webrtc.VideoTrack | ||
| 44 | import java.io.File | 50 | import java.io.File |
| 45 | import java.io.IOException | 51 | import java.io.IOException |
| 46 | import java.util.Date | 52 | import java.util.Date |
| 47 | 53 | ||
| 48 | class MainActivity : ComponentActivity() { | 54 | class MainActivity : ComponentActivity() { |
| 49 | - lateinit var room: Room | ||
| 50 | - var videoFileRenderer: VideoFileRenderer? = null | ||
| 51 | - val connected = MutableLiveData(false) | 55 | + // 录制状态 |
| 56 | + private val isRecording = MutableLiveData(false) | ||
| 57 | + | ||
| 58 | + // WebRTC / 采集组件 | ||
| 59 | + private var eglBase: EglBase? = null | ||
| 60 | + private var factory: PeerConnectionFactory? = null | ||
| 61 | + private var videoCapturer: CameraVideoCapturer? = null | ||
| 62 | + private var surfaceTextureHelper: SurfaceTextureHelper? = null | ||
| 63 | + private var videoSource: VideoSource? = null | ||
| 64 | + private var videoTrack: VideoTrack? = null | ||
| 65 | + private var audioSource: AudioSource? = null | ||
| 66 | + private var audioTrack: AudioTrack? = null | ||
| 67 | + | ||
| 68 | + // 文件渲染器 | ||
| 69 | + private var videoFileRenderer: VideoFileRenderer? = null | ||
| 52 | 70 | ||
| 53 | override fun onCreate(savedInstanceState: Bundle?) { | 71 | override fun onCreate(savedInstanceState: Bundle?) { |
| 54 | super.onCreate(savedInstanceState) | 72 | super.onCreate(savedInstanceState) |
| 55 | 73 | ||
| 56 | - // Create Room object. | ||
| 57 | - room = LiveKit.create( | ||
| 58 | - appContext = applicationContext, | ||
| 59 | - overrides = LiveKitOverrides( | ||
| 60 | - audioOptions = AudioOptions( | ||
| 61 | - javaAudioDeviceModuleCustomizer = { builder -> | ||
| 62 | - // Receive audio samples | ||
| 63 | - builder.setSamplesReadyCallback { samples -> | ||
| 64 | - videoFileRenderer?.onWebRtcAudioRecordSamplesReady(samples) | ||
| 65 | - } | ||
| 66 | - } | ||
| 67 | - ), | ||
| 68 | - ) | ||
| 69 | - ) | ||
| 70 | - | ||
| 71 | setContent { | 74 | setContent { |
| 72 | LivekitandroidTheme { | 75 | LivekitandroidTheme { |
| 73 | Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { | 76 | Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { |
| 74 | Column { | 77 | Column { |
| 75 | - val isConnected by connected.observeAsState(false) | ||
| 76 | - | ||
| 77 | - if (isConnected) { | ||
| 78 | - Text(text = "Connected!") | ||
| 79 | - Button(onClick = { disconnectRoom() }) { | ||
| 80 | - Text("Disconnect") | 78 | + Text(text = "Local Recorder (record-local)") |
| 79 | + // 明确标题 | ||
| 80 | + Text(text = "Local Recorder (record-local)") | ||
| 81 | + val recording by isRecording.observeAsState(false) | ||
| 82 | + | ||
| 83 | + if (recording) { | ||
| 84 | + Text(text = "Recording") | ||
| 85 | + Button(onClick = { stopRecording() }) { | ||
| 86 | + Text("停止录制") | ||
| 81 | } | 87 | } |
| 82 | } else { | 88 | } else { |
| 83 | - Text(text = "Not Connected.") | ||
| 84 | - Button(onClick = { connectToRoom() }) { | ||
| 85 | - Text("Connect") | 89 | + Text(text = "Idle") |
| 90 | + Button(onClick = { startRecording() }) { | ||
| 91 | + Text("开始录制") | ||
| 86 | } | 92 | } |
| 87 | } | 93 | } |
| 88 | } | 94 | } |
| @@ -93,48 +99,109 @@ class MainActivity : ComponentActivity() { | @@ -93,48 +99,109 @@ class MainActivity : ComponentActivity() { | ||
| 93 | requestNeededPermissions() | 99 | requestNeededPermissions() |
| 94 | } | 100 | } |
| 95 | 101 | ||
| 96 | - private fun connectToRoom() { | ||
| 97 | - val url = "https://livekittest-demo.xuedianyun.com/" | ||
| 98 | - val token = "" | ||
| 99 | - | ||
| 100 | - lifecycleScope.launch { | ||
| 101 | - // Connect to server. | ||
| 102 | - room.connect( | ||
| 103 | - url, | ||
| 104 | - token, | ||
| 105 | - ) | ||
| 106 | - | ||
| 107 | - val localParticipant = room.localParticipant | ||
| 108 | - localParticipant.setMicrophoneEnabled(true) | ||
| 109 | - localParticipant.setCameraEnabled(true) | ||
| 110 | - | ||
| 111 | - // Create output file. | ||
| 112 | - val dir = getExternalFilesDir(Environment.DIRECTORY_MOVIES) | ||
| 113 | - val file = File(dir, "${Date().time}.mp4") | ||
| 114 | - if (!file.createNewFile()) { | ||
| 115 | - throw IOException() | ||
| 116 | - } | 102 | + private fun startRecording(withAudio: Boolean = true) { |
| 103 | + // 权限(如需动态申请) | ||
| 104 | + ActivityCompat.requestPermissions( | ||
| 105 | + this, | ||
| 106 | + arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO), | ||
| 107 | + 1001 | ||
| 108 | + ) | ||
| 117 | 109 | ||
| 118 | - // Setup video recording | ||
| 119 | - val videoFileRenderer = VideoFileRenderer( | ||
| 120 | - file.absolutePath, | ||
| 121 | - EglBase.create().eglBaseContext, | ||
| 122 | - true | ||
| 123 | - ) | ||
| 124 | - this@MainActivity.videoFileRenderer = videoFileRenderer | 110 | + // 创建输出文件 |
| 111 | + val dir = getExternalFilesDir(Environment.DIRECTORY_MOVIES) | ||
| 112 | + val file = File(dir, "${Date().time}.mp4") | ||
| 113 | + if (!file.createNewFile()) { | ||
| 114 | + throw IOException() | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + // Egl 与 Renderer | ||
| 118 | + val egl = EglBase.create() | ||
| 119 | + eglBase = egl | ||
| 120 | + val renderer = VideoFileRenderer( | ||
| 121 | + file.absolutePath, | ||
| 122 | + egl.eglBaseContext, | ||
| 123 | + withAudio | ||
| 124 | + ) | ||
| 125 | + videoFileRenderer = renderer | ||
| 125 | 126 | ||
| 126 | - // Attach to local video track. | ||
| 127 | - val track = localParticipant.getTrackPublication(Track.Source.CAMERA)?.track as LocalVideoTrack | ||
| 128 | - track.addRenderer(videoFileRenderer) | 127 | + // 初始化 WebRTC |
| 128 | + PeerConnectionFactory.initialize( | ||
| 129 | + PeerConnectionFactory.InitializationOptions.builder(applicationContext).createInitializationOptions() | ||
| 130 | + ) | ||
| 129 | 131 | ||
| 130 | - connected.value = true | 132 | + val audioModule = JavaAudioDeviceModule.builder(applicationContext) |
| 133 | + .setSamplesReadyCallback { samples -> | ||
| 134 | + videoFileRenderer?.onWebRtcAudioRecordSamplesReady(samples) | ||
| 135 | + } | ||
| 136 | + .createAudioDeviceModule() | ||
| 137 | + | ||
| 138 | + val encoderFactory = DefaultVideoEncoderFactory(egl.eglBaseContext, true, true) | ||
| 139 | + val decoderFactory = DefaultVideoDecoderFactory(egl.eglBaseContext) | ||
| 140 | + | ||
| 141 | + factory = PeerConnectionFactory.builder() | ||
| 142 | + .setAudioDeviceModule(audioModule) | ||
| 143 | + .setVideoEncoderFactory(encoderFactory) | ||
| 144 | + .setVideoDecoderFactory(decoderFactory) | ||
| 145 | + .createPeerConnectionFactory() | ||
| 146 | + | ||
| 147 | + // 视频采集 | ||
| 148 | + surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", egl.eglBaseContext) | ||
| 149 | + val enumerator = Camera2Enumerator(this) | ||
| 150 | + val deviceName = enumerator.deviceNames.find { enumerator.isFrontFacing(it) } ?: enumerator.deviceNames.first() | ||
| 151 | + videoCapturer = enumerator.createCapturer(deviceName, null) | ||
| 152 | + | ||
| 153 | + videoSource = factory?.createVideoSource(false) | ||
| 154 | + videoCapturer?.initialize(surfaceTextureHelper, this, videoSource!!.capturerObserver) | ||
| 155 | + videoCapturer?.startCapture(1280, 720, 30) | ||
| 156 | + | ||
| 157 | + videoTrack = factory?.createVideoTrack("LOCAL_VIDEO", videoSource) | ||
| 158 | + // 将视频帧送入文件渲染器 | ||
| 159 | + videoTrack?.addSink(renderer) | ||
| 160 | + | ||
| 161 | + // 音频(可选) | ||
| 162 | + if (withAudio) { | ||
| 163 | + val audioConstraints = MediaConstraints() | ||
| 164 | + audioSource = factory?.createAudioSource(audioConstraints) | ||
| 165 | + audioTrack = factory?.createAudioTrack("LOCAL_AUDIO", audioSource) | ||
| 166 | + audioTrack?.setEnabled(true) | ||
| 131 | } | 167 | } |
| 168 | + | ||
| 169 | + isRecording.value = true | ||
| 132 | } | 170 | } |
| 133 | 171 | ||
| 134 | - fun disconnectRoom() { | ||
| 135 | - room.disconnect() | 172 | + fun stopRecording() { |
| 173 | + // 停止视频采集 | ||
| 174 | + try { | ||
| 175 | + videoCapturer?.stopCapture() | ||
| 176 | + } catch (_: Exception) {} | ||
| 177 | + videoCapturer?.dispose() | ||
| 178 | + videoCapturer = null | ||
| 179 | + | ||
| 180 | + videoTrack?.dispose() | ||
| 181 | + videoTrack = null | ||
| 182 | + | ||
| 183 | + surfaceTextureHelper?.dispose() | ||
| 184 | + surfaceTextureHelper = null | ||
| 185 | + | ||
| 186 | + videoSource?.dispose() | ||
| 187 | + videoSource = null | ||
| 188 | + | ||
| 189 | + // 停止音频 | ||
| 190 | + audioTrack?.dispose() | ||
| 191 | + audioTrack = null | ||
| 192 | + audioSource?.dispose() | ||
| 193 | + audioSource = null | ||
| 194 | + | ||
| 195 | + // 释放渲染器(写入并关闭 MP4) | ||
| 136 | videoFileRenderer?.release() | 196 | videoFileRenderer?.release() |
| 137 | videoFileRenderer = null | 197 | videoFileRenderer = null |
| 138 | - connected.value = false | 198 | + |
| 199 | + // 释放工厂与 EGL | ||
| 200 | + factory?.dispose() | ||
| 201 | + factory = null | ||
| 202 | + eglBase?.release() | ||
| 203 | + eglBase = null | ||
| 204 | + | ||
| 205 | + isRecording.value = false | ||
| 139 | } | 206 | } |
| 140 | } | 207 | } |
-
请 注册 或 登录 后发表评论