Committed by
GitHub
android screenshare as video track (#16)
* screencast implementation * update usage comment * fill in track source parameter * Make screenCaptureConnection.stop() idempotent * minor cleanup * update compose sample application to include screenshare * fix tests
正在显示
19 个修改的文件
包含
431 行增加
和
22 行删除
| @@ -2,8 +2,8 @@ | @@ -2,8 +2,8 @@ | ||
| 2 | 2 | ||
| 3 | buildscript { | 3 | buildscript { |
| 4 | ext { | 4 | ext { |
| 5 | - compose_version = '1.0.3' | ||
| 6 | - kotlin_version = '1.5.30' | 5 | + compose_version = '1.0.4' |
| 6 | + kotlin_version = '1.5.31' | ||
| 7 | java_version = JavaVersion.VERSION_1_8 | 7 | java_version = JavaVersion.VERSION_1_8 |
| 8 | dokka_version = '1.5.0' | 8 | dokka_version = '1.5.0' |
| 9 | } | 9 | } |
| @@ -5,5 +5,13 @@ | @@ -5,5 +5,13 @@ | ||
| 5 | <uses-permission android:name="android.permission.INTERNET" /> | 5 | <uses-permission android:name="android.permission.INTERNET" /> |
| 6 | <uses-permission android:name="android.permission.RECORD_AUDIO" /> | 6 | <uses-permission android:name="android.permission.RECORD_AUDIO" /> |
| 7 | <uses-permission android:name="android.permission.CAMERA" /> | 7 | <uses-permission android:name="android.permission.CAMERA" /> |
| 8 | + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> | ||
| 8 | 9 | ||
| 10 | + <application> | ||
| 11 | + <service | ||
| 12 | + android:name="io.livekit.android.room.track.screencapture.ScreenCaptureService" | ||
| 13 | + android:enabled="true" | ||
| 14 | + android:foregroundServiceType="mediaProjection" | ||
| 15 | + android:stopWithTask="true" /> | ||
| 16 | + </application> | ||
| 9 | </manifest> | 17 | </manifest> |
| @@ -109,7 +109,7 @@ internal constructor( | @@ -109,7 +109,7 @@ internal constructor( | ||
| 109 | return joinResponse | 109 | return joinResponse |
| 110 | } | 110 | } |
| 111 | 111 | ||
| 112 | - private suspend fun configure(joinResponse: LivekitRtc.JoinResponse) { | 112 | + private fun configure(joinResponse: LivekitRtc.JoinResponse) { |
| 113 | if (this::publisher.isInitialized || this::subscriber.isInitialized) { | 113 | if (this::publisher.isInitialized || this::subscriber.isInitialized) { |
| 114 | // already configured | 114 | // already configured |
| 115 | return | 115 | return |
| @@ -2,6 +2,7 @@ package io.livekit.android.room.participant | @@ -2,6 +2,7 @@ package io.livekit.android.room.participant | ||
| 2 | 2 | ||
| 3 | import android.Manifest | 3 | import android.Manifest |
| 4 | import android.content.Context | 4 | import android.content.Context |
| 5 | +import android.content.Intent | ||
| 5 | import com.google.protobuf.ByteString | 6 | import com.google.protobuf.ByteString |
| 6 | import dagger.assisted.Assisted | 7 | import dagger.assisted.Assisted |
| 7 | import dagger.assisted.AssistedFactory | 8 | import dagger.assisted.AssistedFactory |
| @@ -23,6 +24,7 @@ internal constructor( | @@ -23,6 +24,7 @@ internal constructor( | ||
| 23 | private val peerConnectionFactory: PeerConnectionFactory, | 24 | private val peerConnectionFactory: PeerConnectionFactory, |
| 24 | private val context: Context, | 25 | private val context: Context, |
| 25 | private val eglBase: EglBase, | 26 | private val eglBase: EglBase, |
| 27 | + private val screencastVideoTrackFactory: LocalScreencastVideoTrack.Factory, | ||
| 26 | ) : | 28 | ) : |
| 27 | Participant(info.sid, info.identity) { | 29 | Participant(info.sid, info.identity) { |
| 28 | 30 | ||
| @@ -72,6 +74,27 @@ internal constructor( | @@ -72,6 +74,27 @@ internal constructor( | ||
| 72 | ) | 74 | ) |
| 73 | } | 75 | } |
| 74 | 76 | ||
| 77 | + /** | ||
| 78 | + * Creates a screencast video track. | ||
| 79 | + * | ||
| 80 | + * @param mediaProjectionPermissionResultData The resultData returned from launching | ||
| 81 | + * [MediaProjectionManager.createScreenCaptureIntent()](https://developer.android.com/reference/android/media/projection/MediaProjectionManager#createScreenCaptureIntent()). | ||
| 82 | + */ | ||
| 83 | + fun createScreencastTrack( | ||
| 84 | + name: String = "", | ||
| 85 | + mediaProjectionPermissionResultData: Intent, | ||
| 86 | + ): LocalScreencastVideoTrack { | ||
| 87 | + return LocalScreencastVideoTrack.createTrack( | ||
| 88 | + mediaProjectionPermissionResultData, | ||
| 89 | + peerConnectionFactory, | ||
| 90 | + context, | ||
| 91 | + name, | ||
| 92 | + LocalVideoTrackOptions(isScreencast = true), | ||
| 93 | + eglBase, | ||
| 94 | + screencastVideoTrackFactory | ||
| 95 | + ) | ||
| 96 | + } | ||
| 97 | + | ||
| 75 | suspend fun publishAudioTrack( | 98 | suspend fun publishAudioTrack( |
| 76 | track: LocalAudioTrack, | 99 | track: LocalAudioTrack, |
| 77 | options: AudioTrackPublishOptions = AudioTrackPublishOptions(), | 100 | options: AudioTrackPublishOptions = AudioTrackPublishOptions(), |
| @@ -85,6 +108,7 @@ internal constructor( | @@ -85,6 +108,7 @@ internal constructor( | ||
| 85 | val cid = track.rtcTrack.id() | 108 | val cid = track.rtcTrack.id() |
| 86 | val builder = LivekitRtc.AddTrackRequest.newBuilder().apply { | 109 | val builder = LivekitRtc.AddTrackRequest.newBuilder().apply { |
| 87 | disableDtx = !options.dtx | 110 | disableDtx = !options.dtx |
| 111 | + source = LivekitModels.TrackSource.MICROPHONE | ||
| 88 | } | 112 | } |
| 89 | val trackInfo = engine.addTrack( | 113 | val trackInfo = engine.addTrack( |
| 90 | cid = cid, | 114 | cid = cid, |
| @@ -124,6 +148,11 @@ internal constructor( | @@ -124,6 +148,11 @@ internal constructor( | ||
| 124 | val builder = LivekitRtc.AddTrackRequest.newBuilder().apply { | 148 | val builder = LivekitRtc.AddTrackRequest.newBuilder().apply { |
| 125 | width = track.dimensions.width | 149 | width = track.dimensions.width |
| 126 | height = track.dimensions.height | 150 | height = track.dimensions.height |
| 151 | + source = if(track.options.isScreencast){ | ||
| 152 | + LivekitModels.TrackSource.SCREEN_SHARE | ||
| 153 | + } else { | ||
| 154 | + LivekitModels.TrackSource.CAMERA | ||
| 155 | + } | ||
| 127 | } | 156 | } |
| 128 | val trackInfo = engine.addTrack( | 157 | val trackInfo = engine.addTrack( |
| 129 | cid = cid, | 158 | cid = cid, |
livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalScreencastVideoTrack.kt
0 → 100644
| 1 | +package io.livekit.android.room.track | ||
| 2 | + | ||
| 3 | +import android.app.Notification | ||
| 4 | +import android.content.Context | ||
| 5 | +import android.content.Intent | ||
| 6 | +import android.media.projection.MediaProjection | ||
| 7 | +import dagger.assisted.Assisted | ||
| 8 | +import dagger.assisted.AssistedFactory | ||
| 9 | +import dagger.assisted.AssistedInject | ||
| 10 | +import io.livekit.android.room.track.screencapture.ScreenCaptureConnection | ||
| 11 | +import org.webrtc.* | ||
| 12 | +import java.util.* | ||
| 13 | + | ||
| 14 | +class LocalScreencastVideoTrack | ||
| 15 | +@AssistedInject | ||
| 16 | +constructor( | ||
| 17 | + @Assisted capturer: VideoCapturer, | ||
| 18 | + @Assisted source: VideoSource, | ||
| 19 | + @Assisted name: String, | ||
| 20 | + @Assisted options: LocalVideoTrackOptions, | ||
| 21 | + @Assisted rtcTrack: org.webrtc.VideoTrack, | ||
| 22 | + @Assisted mediaProjectionCallback: MediaProjectionCallback, | ||
| 23 | + peerConnectionFactory: PeerConnectionFactory, | ||
| 24 | + context: Context, | ||
| 25 | + eglBase: EglBase, | ||
| 26 | +) : LocalVideoTrack( | ||
| 27 | + capturer, | ||
| 28 | + source, | ||
| 29 | + name, | ||
| 30 | + options, | ||
| 31 | + rtcTrack, | ||
| 32 | + peerConnectionFactory, | ||
| 33 | + context, | ||
| 34 | + eglBase | ||
| 35 | +) { | ||
| 36 | + | ||
| 37 | + private val serviceConnection = ScreenCaptureConnection(context) | ||
| 38 | + | ||
| 39 | + init { | ||
| 40 | + mediaProjectionCallback.onStopCallback = { stop() } | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /** | ||
| 44 | + * A foreground service is generally required prior to [startCapture]. This method starts up | ||
| 45 | + * a helper foreground service that only serves to display a notification while capturing. This | ||
| 46 | + * foreground service will stop upon the end of screen capture. | ||
| 47 | + * | ||
| 48 | + * You may choose to use your own foreground service instead of this method, but it must be | ||
| 49 | + * started prior to calling [startCapture]. | ||
| 50 | + * | ||
| 51 | + * @see [io.livekit.android.room.track.screencapture.ScreenCaptureService.start] | ||
| 52 | + */ | ||
| 53 | + suspend fun startForegroundService(notificationId: Int?, notification: Notification?) { | ||
| 54 | + serviceConnection.connect() | ||
| 55 | + serviceConnection.startForeground(notificationId, notification) | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + override fun stop() { | ||
| 59 | + super.stop() | ||
| 60 | + serviceConnection.stop() | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + @AssistedFactory | ||
| 64 | + interface Factory { | ||
| 65 | + fun create( | ||
| 66 | + capturer: VideoCapturer, | ||
| 67 | + source: VideoSource, | ||
| 68 | + name: String, | ||
| 69 | + options: LocalVideoTrackOptions, | ||
| 70 | + rtcTrack: org.webrtc.VideoTrack, | ||
| 71 | + mediaProjectionCallback: MediaProjectionCallback, | ||
| 72 | + ): LocalScreencastVideoTrack | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + /** | ||
| 76 | + * Needed to deal with circular dependency. | ||
| 77 | + */ | ||
| 78 | + class MediaProjectionCallback : MediaProjection.Callback() { | ||
| 79 | + var onStopCallback: (() -> Unit)? = null | ||
| 80 | + | ||
| 81 | + override fun onStop() { | ||
| 82 | + onStopCallback?.invoke() | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + companion object { | ||
| 87 | + internal fun createTrack( | ||
| 88 | + mediaProjectionPermissionResultData: Intent, | ||
| 89 | + peerConnectionFactory: PeerConnectionFactory, | ||
| 90 | + context: Context, | ||
| 91 | + name: String, | ||
| 92 | + options: LocalVideoTrackOptions, | ||
| 93 | + rootEglBase: EglBase, | ||
| 94 | + screencastVideoTrackFactory: Factory | ||
| 95 | + ): LocalScreencastVideoTrack { | ||
| 96 | + val source = peerConnectionFactory.createVideoSource(options.isScreencast) | ||
| 97 | + val callback = MediaProjectionCallback() | ||
| 98 | + val capturer = createScreenCapturer(mediaProjectionPermissionResultData, callback) | ||
| 99 | + capturer.initialize( | ||
| 100 | + SurfaceTextureHelper.create("ScreenVideoCaptureThread", rootEglBase.eglBaseContext), | ||
| 101 | + context, | ||
| 102 | + source.capturerObserver | ||
| 103 | + ) | ||
| 104 | + val track = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source) | ||
| 105 | + | ||
| 106 | + return screencastVideoTrackFactory.create( | ||
| 107 | + capturer = capturer, | ||
| 108 | + source = source, | ||
| 109 | + options = options, | ||
| 110 | + name = name, | ||
| 111 | + rtcTrack = track, | ||
| 112 | + mediaProjectionCallback = callback | ||
| 113 | + ) | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + | ||
| 117 | + private fun createScreenCapturer( | ||
| 118 | + resultData: Intent, | ||
| 119 | + callback: MediaProjectionCallback | ||
| 120 | + ): ScreenCapturerAndroid { | ||
| 121 | + return ScreenCapturerAndroid(resultData, callback) | ||
| 122 | + } | ||
| 123 | + } | ||
| 124 | +} |
| @@ -8,12 +8,13 @@ import io.livekit.android.util.LKLog | @@ -8,12 +8,13 @@ import io.livekit.android.util.LKLog | ||
| 8 | import org.webrtc.* | 8 | import org.webrtc.* |
| 9 | import java.util.* | 9 | import java.util.* |
| 10 | 10 | ||
| 11 | + | ||
| 11 | /** | 12 | /** |
| 12 | * A representation of a local video track (generally input coming from camera or screen). | 13 | * A representation of a local video track (generally input coming from camera or screen). |
| 13 | * | 14 | * |
| 14 | * [startCapture] should be called before use. | 15 | * [startCapture] should be called before use. |
| 15 | */ | 16 | */ |
| 16 | -class LocalVideoTrack( | 17 | +open class LocalVideoTrack( |
| 17 | private var capturer: VideoCapturer, | 18 | private var capturer: VideoCapturer, |
| 18 | private var source: VideoSource, | 19 | private var source: VideoSource, |
| 19 | name: String, | 20 | name: String, |
| @@ -40,7 +41,7 @@ class LocalVideoTrack( | @@ -40,7 +41,7 @@ class LocalVideoTrack( | ||
| 40 | private val sender: RtpSender? | 41 | private val sender: RtpSender? |
| 41 | get() = transceiver?.sender | 42 | get() = transceiver?.sender |
| 42 | 43 | ||
| 43 | - fun startCapture() { | 44 | + open fun startCapture() { |
| 44 | capturer.startCapture( | 45 | capturer.startCapture( |
| 45 | options.captureParams.width, | 46 | options.captureParams.width, |
| 46 | options.captureParams.height, | 47 | options.captureParams.height, |
| @@ -157,5 +158,6 @@ class LocalVideoTrack( | @@ -157,5 +158,6 @@ class LocalVideoTrack( | ||
| 157 | } | 158 | } |
| 158 | return null | 159 | return null |
| 159 | } | 160 | } |
| 161 | + | ||
| 160 | } | 162 | } |
| 161 | } | 163 | } |
| 1 | +package io.livekit.android.room.track.screencapture | ||
| 2 | + | ||
| 3 | +import android.app.Notification | ||
| 4 | +import android.content.ComponentName | ||
| 5 | +import android.content.Context | ||
| 6 | +import android.content.Context.BIND_AUTO_CREATE | ||
| 7 | +import android.content.Intent | ||
| 8 | +import android.content.ServiceConnection | ||
| 9 | +import android.os.IBinder | ||
| 10 | +import io.livekit.android.util.LKLog | ||
| 11 | +import kotlinx.coroutines.suspendCancellableCoroutine | ||
| 12 | +import kotlin.coroutines.Continuation | ||
| 13 | +import kotlin.coroutines.resume | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * Handles connecting to a [ScreenCaptureService]. | ||
| 17 | + */ | ||
| 18 | +internal class ScreenCaptureConnection(private val context: Context) { | ||
| 19 | + public var isBound = false | ||
| 20 | + private set | ||
| 21 | + private var service: ScreenCaptureService? = null | ||
| 22 | + private val queuedConnects = mutableSetOf<Continuation<Unit>>() | ||
| 23 | + private val connection: ServiceConnection = object : ServiceConnection { | ||
| 24 | + override fun onServiceDisconnected(name: ComponentName) { | ||
| 25 | + LKLog.v { "Screen capture service is disconnected" } | ||
| 26 | + isBound = false | ||
| 27 | + service = null | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + override fun onServiceConnected(name: ComponentName, binder: IBinder) { | ||
| 31 | + LKLog.v { "Screen capture service is connected" } | ||
| 32 | + val screenCaptureBinder = binder as ScreenCaptureService.ScreenCaptureBinder | ||
| 33 | + service = screenCaptureBinder.service | ||
| 34 | + handleConnect() | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + suspend fun connect() { | ||
| 39 | + if (isBound) { | ||
| 40 | + return | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + val intent = Intent(context, ScreenCaptureService::class.java) | ||
| 44 | + context.bindService(intent, connection, BIND_AUTO_CREATE) | ||
| 45 | + return suspendCancellableCoroutine { | ||
| 46 | + synchronized(this) { | ||
| 47 | + if (isBound) { | ||
| 48 | + it.resume(Unit) | ||
| 49 | + } else { | ||
| 50 | + queuedConnects.add(it) | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + fun startForeground(notificationId: Int? = null, notification: Notification? = null) { | ||
| 57 | + service?.start(notificationId, notification) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + private fun handleConnect() { | ||
| 61 | + synchronized(this) { | ||
| 62 | + isBound = true | ||
| 63 | + queuedConnects.forEach { it.resume(Unit) } | ||
| 64 | + queuedConnects.clear() | ||
| 65 | + } | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + fun stop() { | ||
| 69 | + if (isBound) { | ||
| 70 | + context.unbindService(connection) | ||
| 71 | + } | ||
| 72 | + service = null | ||
| 73 | + isBound = false | ||
| 74 | + } | ||
| 75 | +} |
| 1 | +package io.livekit.android.room.track.screencapture | ||
| 2 | + | ||
| 3 | +import android.app.Notification | ||
| 4 | +import android.app.NotificationChannel | ||
| 5 | +import android.app.NotificationManager | ||
| 6 | +import android.app.Service | ||
| 7 | +import android.content.Context | ||
| 8 | +import android.content.Intent | ||
| 9 | +import android.os.Binder | ||
| 10 | +import android.os.Build | ||
| 11 | +import android.os.IBinder | ||
| 12 | +import androidx.annotation.RequiresApi | ||
| 13 | +import androidx.core.app.NotificationCompat | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * A foreground service is required for screen capture on API level Q (29) and up. | ||
| 17 | + * This a simple default foreground service to display a notification while screen | ||
| 18 | + * capturing. | ||
| 19 | + */ | ||
| 20 | + | ||
| 21 | +open class ScreenCaptureService : Service() { | ||
| 22 | + private var binder: IBinder = ScreenCaptureBinder() | ||
| 23 | + private var bindCount = 0 | ||
| 24 | + override fun onBind(intent: Intent?): IBinder { | ||
| 25 | + bindCount++ | ||
| 26 | + return binder | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + /** | ||
| 30 | + * @param notificationId id of the notification to be used, or null for [DEFAULT_CHANNEL_ID] | ||
| 31 | + * @param notification notification to be used, or null for a default notification. | ||
| 32 | + */ | ||
| 33 | + fun start(notificationId: Int?, notification: Notification?) { | ||
| 34 | + val actualNotification = if (notification != null) { | ||
| 35 | + notification | ||
| 36 | + } else { | ||
| 37 | + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||
| 38 | + createNotificationChannel() | ||
| 39 | + } | ||
| 40 | + NotificationCompat.Builder(this, DEFAULT_CHANNEL_ID) | ||
| 41 | + .setPriority(NotificationCompat.PRIORITY_DEFAULT) | ||
| 42 | + .build() | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + val actualId = notificationId ?: DEFAULT_NOTIFICATION_ID | ||
| 46 | + startForeground(actualId, actualNotification) | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + @RequiresApi(Build.VERSION_CODES.O) | ||
| 50 | + private fun createNotificationChannel() { | ||
| 51 | + val channel = NotificationChannel( | ||
| 52 | + DEFAULT_CHANNEL_ID, | ||
| 53 | + "Screen Capture", | ||
| 54 | + NotificationManager.IMPORTANCE_LOW | ||
| 55 | + ) | ||
| 56 | + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager | ||
| 57 | + service.createNotificationChannel(channel) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + override fun onUnbind(intent: Intent?): Boolean { | ||
| 61 | + bindCount-- | ||
| 62 | + | ||
| 63 | + if (bindCount == 0) { | ||
| 64 | + stopSelf() | ||
| 65 | + } | ||
| 66 | + return false | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + | ||
| 70 | + inner class ScreenCaptureBinder : Binder() { | ||
| 71 | + val service: ScreenCaptureService | ||
| 72 | + get() = this@ScreenCaptureService | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + companion object { | ||
| 76 | + const val DEFAULT_NOTIFICATION_ID = 2345 | ||
| 77 | + const val DEFAULT_CHANNEL_ID = "livekit_screen_capture" | ||
| 78 | + } | ||
| 79 | +} |
| @@ -14,6 +14,7 @@ import org.junit.Rule | @@ -14,6 +14,7 @@ import org.junit.Rule | ||
| 14 | import org.junit.Test | 14 | import org.junit.Test |
| 15 | import org.junit.runner.RunWith | 15 | import org.junit.runner.RunWith |
| 16 | import org.mockito.Mock | 16 | import org.mockito.Mock |
| 17 | +import org.mockito.Mockito | ||
| 17 | import org.mockito.junit.MockitoJUnit | 18 | import org.mockito.junit.MockitoJUnit |
| 18 | import org.robolectric.RobolectricTestRunner | 19 | import org.robolectric.RobolectricTestRunner |
| 19 | import org.webrtc.EglBase | 20 | import org.webrtc.EglBase |
| @@ -31,19 +32,11 @@ class RoomTest { | @@ -31,19 +32,11 @@ class RoomTest { | ||
| 31 | @Mock | 32 | @Mock |
| 32 | lateinit var rtcEngine: RTCEngine | 33 | lateinit var rtcEngine: RTCEngine |
| 33 | 34 | ||
| 34 | - @Mock | ||
| 35 | - lateinit var peerConnectionFactory: PeerConnectionFactory | ||
| 36 | var eglBase: EglBase = MockEglBase() | 35 | var eglBase: EglBase = MockEglBase() |
| 37 | 36 | ||
| 38 | val localParticantFactory = object : LocalParticipant.Factory { | 37 | val localParticantFactory = object : LocalParticipant.Factory { |
| 39 | override fun create(info: LivekitModels.ParticipantInfo): LocalParticipant { | 38 | override fun create(info: LivekitModels.ParticipantInfo): LocalParticipant { |
| 40 | - return LocalParticipant( | ||
| 41 | - info, | ||
| 42 | - rtcEngine, | ||
| 43 | - peerConnectionFactory, | ||
| 44 | - context, | ||
| 45 | - eglBase, | ||
| 46 | - ) | 39 | + return Mockito.mock(LocalParticipant::class.java) |
| 47 | } | 40 | } |
| 48 | } | 41 | } |
| 49 | 42 |
| 1 | package io.livekit.android.composesample | 1 | package io.livekit.android.composesample |
| 2 | 2 | ||
| 3 | +import android.app.Activity | ||
| 3 | import android.media.AudioManager | 4 | import android.media.AudioManager |
| 5 | +import android.media.projection.MediaProjectionManager | ||
| 4 | import android.os.Bundle | 6 | import android.os.Bundle |
| 5 | import android.os.Parcelable | 7 | import android.os.Parcelable |
| 6 | import androidx.activity.compose.setContent | 8 | import androidx.activity.compose.setContent |
| 9 | +import androidx.activity.result.contract.ActivityResultContracts | ||
| 7 | import androidx.appcompat.app.AppCompatActivity | 10 | import androidx.appcompat.app.AppCompatActivity |
| 8 | import androidx.compose.foundation.background | 11 | import androidx.compose.foundation.background |
| 9 | import androidx.compose.foundation.layout.* | 12 | import androidx.compose.foundation.layout.* |
| @@ -44,6 +47,19 @@ class CallActivity : AppCompatActivity() { | @@ -44,6 +47,19 @@ class CallActivity : AppCompatActivity() { | ||
| 44 | private var previousSpeakerphoneOn = true | 47 | private var previousSpeakerphoneOn = true |
| 45 | private var previousMicrophoneMute = false | 48 | private var previousMicrophoneMute = false |
| 46 | 49 | ||
| 50 | + private val screenCaptureIntentLauncher = | ||
| 51 | + registerForActivityResult( | ||
| 52 | + ActivityResultContracts.StartActivityForResult() | ||
| 53 | + ) { result -> | ||
| 54 | + val resultCode = result.resultCode | ||
| 55 | + val data = result.data | ||
| 56 | + if (resultCode != Activity.RESULT_OK || data == null) { | ||
| 57 | + return@registerForActivityResult | ||
| 58 | + } | ||
| 59 | + viewModel.startScreenCapture(data) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + | ||
| 47 | override fun onCreate(savedInstanceState: Bundle?) { | 63 | override fun onCreate(savedInstanceState: Bundle?) { |
| 48 | super.onCreate(savedInstanceState) | 64 | super.onCreate(savedInstanceState) |
| 49 | 65 | ||
| @@ -73,17 +89,25 @@ class CallActivity : AppCompatActivity() { | @@ -73,17 +89,25 @@ class CallActivity : AppCompatActivity() { | ||
| 73 | val micEnabled by viewModel.micEnabled.observeAsState(true) | 89 | val micEnabled by viewModel.micEnabled.observeAsState(true) |
| 74 | val videoEnabled by viewModel.videoEnabled.observeAsState(true) | 90 | val videoEnabled by viewModel.videoEnabled.observeAsState(true) |
| 75 | val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true) | 91 | val flipButtonEnabled by viewModel.flipButtonVideoEnabled.observeAsState(true) |
| 92 | + val screencastEnabled by viewModel.screencastEnabled.observeAsState(false) | ||
| 76 | Content( | 93 | Content( |
| 77 | room, | 94 | room, |
| 78 | participants, | 95 | participants, |
| 79 | micEnabled, | 96 | micEnabled, |
| 80 | videoEnabled, | 97 | videoEnabled, |
| 81 | - flipButtonEnabled | 98 | + flipButtonEnabled, |
| 99 | + screencastEnabled, | ||
| 82 | ) | 100 | ) |
| 83 | } | 101 | } |
| 84 | } | 102 | } |
| 85 | } | 103 | } |
| 86 | 104 | ||
| 105 | + private fun requestMediaProjection() { | ||
| 106 | + val mediaProjectionManager = | ||
| 107 | + getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager | ||
| 108 | + screenCaptureIntentLauncher.launch(mediaProjectionManager.createScreenCaptureIntent()) | ||
| 109 | + } | ||
| 110 | + | ||
| 87 | @Preview(showBackground = true, showSystemUi = true) | 111 | @Preview(showBackground = true, showSystemUi = true) |
| 88 | @Composable | 112 | @Composable |
| 89 | fun Content( | 113 | fun Content( |
| @@ -92,6 +116,7 @@ class CallActivity : AppCompatActivity() { | @@ -92,6 +116,7 @@ class CallActivity : AppCompatActivity() { | ||
| 92 | micEnabled: Boolean = true, | 116 | micEnabled: Boolean = true, |
| 93 | videoEnabled: Boolean = true, | 117 | videoEnabled: Boolean = true, |
| 94 | flipButtonEnabled: Boolean = true, | 118 | flipButtonEnabled: Boolean = true, |
| 119 | + screencastEnabled: Boolean = false, | ||
| 95 | ) { | 120 | ) { |
| 96 | ConstraintLayout( | 121 | ConstraintLayout( |
| 97 | modifier = Modifier | 122 | modifier = Modifier |
| @@ -224,6 +249,24 @@ class CallActivity : AppCompatActivity() { | @@ -224,6 +249,24 @@ class CallActivity : AppCompatActivity() { | ||
| 224 | tint = Color.White, | 249 | tint = Color.White, |
| 225 | ) | 250 | ) |
| 226 | } | 251 | } |
| 252 | + FloatingActionButton( | ||
| 253 | + onClick = { | ||
| 254 | + if (!screencastEnabled) { | ||
| 255 | + requestMediaProjection() | ||
| 256 | + } else { | ||
| 257 | + viewModel.stopScreenCapture() | ||
| 258 | + } | ||
| 259 | + }, | ||
| 260 | + backgroundColor = Color.DarkGray, | ||
| 261 | + ) { | ||
| 262 | + val resource = | ||
| 263 | + if (screencastEnabled) R.drawable.baseline_cast_connected_24 else R.drawable.baseline_cast_24 | ||
| 264 | + Icon( | ||
| 265 | + painterResource(id = resource), | ||
| 266 | + contentDescription = "Flip Camera", | ||
| 267 | + tint = Color.White, | ||
| 268 | + ) | ||
| 269 | + } | ||
| 227 | } | 270 | } |
| 228 | } | 271 | } |
| 229 | } | 272 | } |
| 1 | package io.livekit.android.composesample | 1 | package io.livekit.android.composesample |
| 2 | 2 | ||
| 3 | import android.app.Application | 3 | import android.app.Application |
| 4 | +import android.content.Intent | ||
| 4 | import androidx.lifecycle.AndroidViewModel | 5 | import androidx.lifecycle.AndroidViewModel |
| 5 | import androidx.lifecycle.LiveData | 6 | import androidx.lifecycle.LiveData |
| 6 | import androidx.lifecycle.MutableLiveData | 7 | import androidx.lifecycle.MutableLiveData |
| @@ -10,12 +11,11 @@ import io.livekit.android.ConnectOptions | @@ -10,12 +11,11 @@ import io.livekit.android.ConnectOptions | ||
| 10 | import io.livekit.android.LiveKit | 11 | import io.livekit.android.LiveKit |
| 11 | import io.livekit.android.room.Room | 12 | import io.livekit.android.room.Room |
| 12 | import io.livekit.android.room.RoomListener | 13 | import io.livekit.android.room.RoomListener |
| 14 | +import io.livekit.android.room.participant.AudioTrackPublishOptions | ||
| 13 | import io.livekit.android.room.participant.Participant | 15 | import io.livekit.android.room.participant.Participant |
| 14 | import io.livekit.android.room.participant.RemoteParticipant | 16 | import io.livekit.android.room.participant.RemoteParticipant |
| 15 | -import io.livekit.android.room.track.CameraPosition | ||
| 16 | -import io.livekit.android.room.track.LocalAudioTrack | ||
| 17 | -import io.livekit.android.room.track.LocalVideoTrack | ||
| 18 | -import io.livekit.android.room.track.LocalVideoTrackOptions | 17 | +import io.livekit.android.room.participant.VideoTrackPublishOptions |
| 18 | +import io.livekit.android.room.track.* | ||
| 19 | import kotlinx.coroutines.launch | 19 | import kotlinx.coroutines.launch |
| 20 | 20 | ||
| 21 | class CallViewModel( | 21 | class CallViewModel( |
| @@ -30,6 +30,7 @@ class CallViewModel( | @@ -30,6 +30,7 @@ class CallViewModel( | ||
| 30 | 30 | ||
| 31 | private var localAudioTrack: LocalAudioTrack? = null | 31 | private var localAudioTrack: LocalAudioTrack? = null |
| 32 | private var localVideoTrack: LocalVideoTrack? = null | 32 | private var localVideoTrack: LocalVideoTrack? = null |
| 33 | + private var localScreencastTrack: LocalScreencastVideoTrack? = null | ||
| 33 | 34 | ||
| 34 | private val mutableMicEnabled = MutableLiveData(true) | 35 | private val mutableMicEnabled = MutableLiveData(true) |
| 35 | val micEnabled = mutableMicEnabled.hide() | 36 | val micEnabled = mutableMicEnabled.hide() |
| @@ -40,6 +41,9 @@ class CallViewModel( | @@ -40,6 +41,9 @@ class CallViewModel( | ||
| 40 | private val mutableFlipVideoButtonEnabled = MutableLiveData(true) | 41 | private val mutableFlipVideoButtonEnabled = MutableLiveData(true) |
| 41 | val flipButtonVideoEnabled = mutableFlipVideoButtonEnabled.hide() | 42 | val flipButtonVideoEnabled = mutableFlipVideoButtonEnabled.hide() |
| 42 | 43 | ||
| 44 | + private val mutableScreencastEnabled = MutableLiveData(false) | ||
| 45 | + val screencastEnabled = mutableScreencastEnabled.hide() | ||
| 46 | + | ||
| 43 | init { | 47 | init { |
| 44 | viewModelScope.launch { | 48 | viewModelScope.launch { |
| 45 | val room = LiveKit.connect( | 49 | val room = LiveKit.connect( |
| @@ -50,14 +54,18 @@ class CallViewModel( | @@ -50,14 +54,18 @@ class CallViewModel( | ||
| 50 | this@CallViewModel | 54 | this@CallViewModel |
| 51 | ) | 55 | ) |
| 52 | 56 | ||
| 57 | + // Create and publish audio/video tracks | ||
| 53 | val localParticipant = room.localParticipant | 58 | val localParticipant = room.localParticipant |
| 54 | val audioTrack = localParticipant.createAudioTrack() | 59 | val audioTrack = localParticipant.createAudioTrack() |
| 55 | - localParticipant.publishAudioTrack(audioTrack) | 60 | + localParticipant.publishAudioTrack(audioTrack, AudioTrackPublishOptions(dtx = true)) |
| 56 | this@CallViewModel.localAudioTrack = audioTrack | 61 | this@CallViewModel.localAudioTrack = audioTrack |
| 57 | mutableMicEnabled.postValue(audioTrack.enabled) | 62 | mutableMicEnabled.postValue(audioTrack.enabled) |
| 58 | 63 | ||
| 59 | val videoTrack = localParticipant.createVideoTrack() | 64 | val videoTrack = localParticipant.createVideoTrack() |
| 60 | - localParticipant.publishVideoTrack(videoTrack) | 65 | + localParticipant.publishVideoTrack( |
| 66 | + videoTrack, | ||
| 67 | + VideoTrackPublishOptions(simulcast = false) | ||
| 68 | + ) | ||
| 61 | videoTrack.startCapture() | 69 | videoTrack.startCapture() |
| 62 | this@CallViewModel.localVideoTrack = videoTrack | 70 | this@CallViewModel.localVideoTrack = videoTrack |
| 63 | mutableVideoEnabled.postValue(videoTrack.enabled) | 71 | mutableVideoEnabled.postValue(videoTrack.enabled) |
| @@ -67,6 +75,34 @@ class CallViewModel( | @@ -67,6 +75,34 @@ class CallViewModel( | ||
| 67 | } | 75 | } |
| 68 | } | 76 | } |
| 69 | 77 | ||
| 78 | + fun startScreenCapture(mediaProjectionPermissionResultData: Intent) { | ||
| 79 | + val localParticipant = room.value?.localParticipant ?: return | ||
| 80 | + viewModelScope.launch { | ||
| 81 | + val screencastTrack = | ||
| 82 | + localParticipant.createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData) | ||
| 83 | + localParticipant.publishVideoTrack( | ||
| 84 | + screencastTrack | ||
| 85 | + ) | ||
| 86 | + | ||
| 87 | + // Must start the foreground prior to startCapture. | ||
| 88 | + screencastTrack.startForegroundService(null, null) | ||
| 89 | + screencastTrack.startCapture() | ||
| 90 | + | ||
| 91 | + this@CallViewModel.localScreencastTrack = screencastTrack | ||
| 92 | + mutableScreencastEnabled.postValue(screencastTrack.enabled) | ||
| 93 | + } | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + fun stopScreenCapture() { | ||
| 97 | + viewModelScope.launch { | ||
| 98 | + localScreencastTrack?.let { localScreencastVideoTrack -> | ||
| 99 | + localScreencastVideoTrack.stop() | ||
| 100 | + room.value?.localParticipant?.unpublishTrack(localScreencastVideoTrack) | ||
| 101 | + mutableScreencastEnabled.postValue(localScreencastTrack?.enabled ?: false) | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | + | ||
| 70 | private fun updateParticipants(room: Room) { | 106 | private fun updateParticipants(room: Room) { |
| 71 | mutableRemoteParticipants.postValue( | 107 | mutableRemoteParticipants.postValue( |
| 72 | room.remoteParticipants | 108 | room.remoteParticipants |
| @@ -8,6 +8,6 @@ class SampleApplication : Application() { | @@ -8,6 +8,6 @@ class SampleApplication : Application() { | ||
| 8 | 8 | ||
| 9 | override fun onCreate() { | 9 | override fun onCreate() { |
| 10 | super.onCreate() | 10 | super.onCreate() |
| 11 | - LiveKit.loggingLevel = LoggingLevel.OFF | 11 | + LiveKit.loggingLevel = LoggingLevel.VERBOSE |
| 12 | } | 12 | } |
| 13 | } | 13 | } |
| 1 | +<vector xmlns:android="http://schemas.android.com/apk/res/android" | ||
| 2 | + android:width="24dp" | ||
| 3 | + android:height="24dp" | ||
| 4 | + android:viewportWidth="24" | ||
| 5 | + android:viewportHeight="24" | ||
| 6 | + android:tint="?attr/colorControlNormal"> | ||
| 7 | + <path | ||
| 8 | + android:fillColor="@android:color/white" | ||
| 9 | + android:pathData="M21,3L3,3c-1.1,0 -2,0.9 -2,2v3h2L3,5h18v14h-7v2h7c1.1,0 2,-0.9 2,-2L23,5c0,-1.1 -0.9,-2 -2,-2zM1,18v3h3c0,-1.66 -1.34,-3 -3,-3zM1,14v2c2.76,0 5,2.24 5,5h2c0,-3.87 -3.13,-7 -7,-7zM1,10v2c4.97,0 9,4.03 9,9h2c0,-6.08 -4.93,-11 -11,-11z"/> | ||
| 10 | +</vector> |
| 1 | +<vector xmlns:android="http://schemas.android.com/apk/res/android" | ||
| 2 | + android:width="24dp" | ||
| 3 | + android:height="24dp" | ||
| 4 | + android:viewportWidth="24" | ||
| 5 | + android:viewportHeight="24" | ||
| 6 | + android:tint="?attr/colorControlNormal"> | ||
| 7 | + <path | ||
| 8 | + android:fillColor="@android:color/white" | ||
| 9 | + android:pathData="M1,18v3h3c0,-1.66 -1.34,-3 -3,-3zM1,14v2c2.76,0 5,2.24 5,5h2c0,-3.87 -3.13,-7 -7,-7zM19,7L5,7v1.63c3.96,1.28 7.09,4.41 8.37,8.37L19,17L19,7zM1,10v2c4.97,0 9,4.03 9,9h2c0,-6.08 -4.93,-11 -11,-11zM21,3L3,3c-1.1,0 -2,0.9 -2,2v3h2L3,5h18v14h-7v2h7c1.1,0 2,-0.9 2,-2L23,5c0,-1.1 -0.9,-2 -2,-2z"/> | ||
| 10 | +</vector> |
-
请 注册 或 登录 后发表评论