diff --git a/android/sender/README.md b/android/sender/README.md
index cf9a878..a6a3216 100644
--- a/android/sender/README.md
+++ b/android/sender/README.md
@@ -1,8 +1,8 @@
# Android Screen Sender
This source-only P1 slice lets an Android 14/API 34+ device publish a 720p30
-screen track to at most two Web Viewers assigned as direct children by the
-existing `screener-v2` controller.
+screen track and optional selected-app playback-audio track to at most two Web
+Viewers assigned as direct children by the existing `screener-v2` controller.
## Build
@@ -19,9 +19,18 @@ configured.
## Use
Open the app, enter the HTTPS Screener base URL and site-access password, then
-choose a screen or app from Android's system picker. The app keeps credentials
-and room material in memory only. The displayed private invite can be opened by
-an unchanged Web Viewer.
+leave playback audio off or explicitly select one app, then choose a screen or
+app from Android's system picker. Audio is selected separately because Android
+does not expose the system picker's selected package/UID; it may therefore differ
+from the shared picture. The app keeps credentials and room material in memory
+only. The displayed private invite can be opened by an unchanged Web Viewer.
+
+Enabling playback audio requests `RECORD_AUDIO`, but the sender disables the
+WebRTC audio device module's microphone input. It filters playback to the
+selected app UID and eligible game/media usages. Apps may block capture or have
+no eligible playback, in which case the track can remain silent; there is no
+microphone or whole-system fallback. “Playback requested” does not claim that
+audible media was observed.
The foreground notification and system capture indicator remain visible for the
whole session. Stopping from the app or system picker closes capture, signaling,
@@ -29,7 +38,9 @@ and every PeerConnection.
## First-Slice Boundary
-- Video only: no playback-capture audio or A/V synchronization yet.
+- Playback audio is default-off and limited to one explicitly selected,
+ single-package UID. Background continuity, silence detection and A/V
+ synchronization remain device gates.
- Hardware VP8/H.264 encoders only; there is no software encoder fallback.
- At most two STUN-only direct-child edges. The server remains the route owner.
- SFU publication and selected-edge TURN are not implemented. The client reports
diff --git a/android/sender/app/src/main/AndroidManifest.xml b/android/sender/app/src/main/AndroidManifest.xml
index 4d8ca90..a7285a7 100644
--- a/android/sender/app/src/main/AndroidManifest.xml
+++ b/android/sender/app/src/main/AndroidManifest.xml
@@ -4,6 +4,14 @@
+
+
+
+
+
+
+
+
Unit,
+ onAudioUnavailable: () -> Unit,
) : AutoCloseable {
private lateinit var egl: EglBase
lateinit var factory: PeerConnectionFactory
private set
lateinit var track: VideoTrack
private set
+ var audioTrack: AudioTrack? = null
+ private set
private lateinit var capturer: ScreenCapturerAndroid
private lateinit var source: VideoSource
private lateinit var helper: SurfaceTextureHelper
+ private var audioSource: AudioSource? = null
+ private var audioCapture: PlaybackAudioCapture? = null
+ private var audioDeviceModule: JavaAudioDeviceModule? = null
private var closed = false
init {
@@ -37,10 +50,25 @@ class CaptureEngine private constructor(
egl = EglBase.create()
val encoderFactory = HardwareOnlyEncoderFactory(egl.eglBaseContext)
if (encoderFactory.supportedCodecs.isEmpty()) error("No hardware VP8/H264 encoder is available")
- factory = PeerConnectionFactory.builder()
+ val factoryBuilder = PeerConnectionFactory.builder()
.setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(DefaultVideoDecoderFactory(egl.eglBaseContext))
- .createPeerConnectionFactory()
+ audioTargetUid?.let { uid ->
+ val playback = PlaybackAudioCapture(uid, onAudioUnavailable)
+ audioCapture = playback
+ val deviceModule = JavaAudioDeviceModule.builder(context.applicationContext)
+ .setInputSampleRate(PlaybackAudioCapture.SAMPLE_RATE)
+ .setAudioFormat(AudioFormat.ENCODING_PCM_16BIT)
+ .setUseStereoInput(false)
+ .setUseHardwareAcousticEchoCanceler(false)
+ .setUseHardwareNoiseSuppressor(false)
+ .setAudioBufferCallback(playback)
+ .createAudioDeviceModule()
+ .also { it.setAudioRecordEnabled(false) }
+ audioDeviceModule = deviceModule
+ factoryBuilder.setAudioDeviceModule(deviceModule)
+ }
+ factory = factoryBuilder.createPeerConnectionFactory()
source = factory.createVideoSource(true)
helper = SurfaceTextureHelper.create("ScreenerCapture", egl.eglBaseContext)
capturer = ScreenCapturerAndroid(permissionData, object : MediaProjection.Callback() {
@@ -50,6 +78,22 @@ class CaptureEngine private constructor(
capturer.startCapture(WIDTH, HEIGHT, FPS)
track = factory.createVideoTrack("screener-screen", source)
track.setEnabled(true)
+ val playback = audioCapture
+ if (playback != null && capturer.mediaProjection?.let(playback::start) == true) {
+ val createdSource = factory.createAudioSource(MediaConstraints())
+ audioSource = createdSource
+ val createdTrack = factory.createAudioTrack("screener-playback", createdSource)
+ if (createdTrack.setAudioProcessingOptions(AudioProcessingOptions.raw()).isSuccess) {
+ createdTrack.setEnabled(true)
+ audioTrack = createdTrack
+ } else {
+ createdTrack.dispose()
+ createdSource.dispose()
+ audioSource = null
+ playback.close()
+ onAudioUnavailable()
+ }
+ }
} catch (error: Throwable) {
release()
throw error
@@ -64,9 +108,17 @@ class CaptureEngine private constructor(
if (::capturer.isInitialized) runCatching { capturer.stopCapture() }
if (::capturer.isInitialized) runCatching { capturer.dispose() }
if (::track.isInitialized) runCatching { track.dispose() }
+ audioCapture?.close()
+ audioCapture = null
+ audioTrack?.let { runCatching { it.dispose() } }
+ audioTrack = null
+ audioSource?.let { runCatching { it.dispose() } }
+ audioSource = null
if (::source.isInitialized) runCatching { source.dispose() }
if (::helper.isInitialized) runCatching { helper.dispose() }
if (::factory.isInitialized) runCatching { factory.dispose() }
+ audioDeviceModule?.let { runCatching { it.release() } }
+ audioDeviceModule = null
if (::egl.isInitialized) runCatching { egl.release() }
}
@@ -88,8 +140,13 @@ class CaptureEngine private constructor(
const val FPS = 30
private val initialized = AtomicBoolean()
- fun create(context: Context, permissionData: Intent, onProjectionStopped: () -> Unit) =
- CaptureEngine(context, permissionData, onProjectionStopped)
+ fun create(
+ context: Context,
+ permissionData: Intent,
+ audioTargetUid: Int?,
+ onProjectionStopped: () -> Unit,
+ onAudioUnavailable: () -> Unit,
+ ) = CaptureEngine(context, permissionData, audioTargetUid, onProjectionStopped, onAudioUnavailable)
private fun initializeOnce(context: Context) {
if (initialized.compareAndSet(false, true)) {
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt
index 2064b0e..612cf4e 100644
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/HostPeer.kt
@@ -4,6 +4,7 @@ import icu.bonfire.screener.protocol.IceConfig
import icu.bonfire.screener.protocol.SignalPayload
import icu.bonfire.screener.protocol.Wire
import icu.bonfire.screener.protocol.IceCandidate as WireCandidate
+import org.webrtc.AudioTrack
import org.webrtc.DataChannel
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
@@ -22,6 +23,7 @@ class HostPeer(
iceConfig: IceConfig,
factory: PeerConnectionFactory,
track: VideoTrack,
+ audioTrack: AudioTrack?,
private val serial: Executor,
private val send: (String) -> Boolean,
private val status: (String) -> Unit,
@@ -45,6 +47,9 @@ class HostPeer(
parameters.encodings.forEach { it.maxBitrateBps = 3_000_000 }
if (!sender.setParameters(parameters)) error("Could not set video bitrate")
}
+ audioTrack?.let {
+ connection.addTrack(it, listOf("screener")) ?: error("Could not add playback audio track")
+ }
} catch (error: Throwable) {
closed = true
runCatching { connection.dispose() }
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt
index 64a3ca4..c490a3e 100644
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/MainActivity.kt
@@ -1,16 +1,20 @@
package icu.bonfire.screener.sender
+import android.Manifest
import android.app.Activity
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ResultReceiver
+import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
+import android.widget.Spinner
import android.widget.TextView
class MainActivity : Activity() {
@@ -18,10 +22,13 @@ class MainActivity : Activity() {
private lateinit var sitePassword: EditText
private lateinit var startButton: Button
private lateinit var stopButton: Button
+ private lateinit var audioTarget: Spinner
private lateinit var statusText: TextView
private lateinit var inviteText: TextView
private var pendingServer = ""
private var pendingPassword = ""
+ private var pendingAudioUid: Int? = null
+ private var pendingAudioName: String? = null
private val receiver = object : ResultReceiver(Handler(Looper.getMainLooper())) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
@@ -48,10 +55,17 @@ class MainActivity : Activity() {
sitePassword = findViewById(R.id.site_password)
startButton = findViewById(R.id.start_button)
stopButton = findViewById(R.id.stop_button)
+ audioTarget = findViewById(R.id.audio_target)
statusText = findViewById(R.id.status_text)
inviteText = findViewById(R.id.invite_text)
- startButton.setOnClickListener { requestProjection() }
+ audioTarget.adapter = ArrayAdapter(
+ this,
+ android.R.layout.simple_spinner_item,
+ loadAudioTargets(),
+ ).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
+
+ startButton.setOnClickListener { prepareProjection() }
stopButton.setOnClickListener {
startService(Intent(this, ProjectionService::class.java).setAction(ProjectionService.ACTION_STOP))
clearInvite()
@@ -59,18 +73,46 @@ class MainActivity : Activity() {
}
}
- private fun requestProjection() {
+ private fun prepareProjection() {
pendingServer = serverUrl.text.toString().trim().trimEnd('/')
pendingPassword = sitePassword.text.toString()
+ val target = audioTarget.selectedItem as AudioTarget
+ pendingAudioUid = target.uid
+ pendingAudioName = target.packageName?.let { target.label }
clearInvite()
if (!pendingServer.startsWith("https://")) {
statusText.text = "An HTTPS server URL is required"
return
}
+ if (
+ pendingAudioUid != null &&
+ checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED
+ ) {
+ requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_AUDIO)
+ return
+ }
+ requestProjection()
+ }
+
+ private fun requestProjection() {
val manager = getSystemService(MediaProjectionManager::class.java)
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_PROJECTION)
}
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray,
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode != REQUEST_AUDIO) return
+ if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
+ requestProjection()
+ } else {
+ statusText.text = "Playback audio permission was denied"
+ }
+ }
+
@Deprecated("The platform projection picker still returns through this callback")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@@ -88,12 +130,30 @@ class MainActivity : Activity() {
putExtra(ProjectionService.EXTRA_SITE_PASSWORD, pendingPassword)
putExtra(ProjectionService.EXTRA_PERMISSION_DATA, data)
putExtra(ProjectionService.EXTRA_RECEIVER, receiver)
+ pendingAudioUid?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_UID, it) }
+ pendingAudioName?.let { putExtra(ProjectionService.EXTRA_AUDIO_TARGET_NAME, it) }
}
startForegroundService(service)
sitePassword.text.clear()
pendingPassword = ""
}
+ private fun loadAudioTargets(): List {
+ val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+ val targets = packageManager.queryIntentActivities(
+ launcher,
+ PackageManager.ResolveInfoFlags.of(0L),
+ ).mapNotNull { resolved ->
+ val app = resolved.activityInfo?.applicationInfo ?: return@mapNotNull null
+ if (app.uid == applicationInfo.uid) return@mapNotNull null
+ val packages = packageManager.getPackagesForUid(app.uid)?.distinct()
+ if (packages?.singleOrNull() != app.packageName) return@mapNotNull null
+ val label = app.loadLabel(packageManager).toString().trim().ifEmpty { app.packageName }
+ AudioTarget(label, app.packageName, app.uid)
+ }.distinctBy { it.uid }.sortedBy { it.label.lowercase() }
+ return listOf(AudioTarget("Off (video only)", null, null)) + targets
+ }
+
private fun clearInvite() {
inviteText.text = ""
inviteText.setOnClickListener(null)
@@ -101,5 +161,10 @@ class MainActivity : Activity() {
companion object {
private const val REQUEST_PROJECTION = 40
+ private const val REQUEST_AUDIO = 41
+ }
+
+ private data class AudioTarget(val label: String, val packageName: String?, val uid: Int?) {
+ override fun toString(): String = packageName?.let { "$label ($it)" } ?: label
}
}
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt
new file mode 100644
index 0000000..62eaf7e
--- /dev/null
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/PlaybackAudioCapture.kt
@@ -0,0 +1,130 @@
+package icu.bonfire.screener.sender
+
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioPlaybackCaptureConfiguration
+import android.media.AudioRecord
+import android.media.projection.MediaProjection
+import android.os.SystemClock
+import org.webrtc.audio.JavaAudioDeviceModule
+import java.nio.ByteBuffer
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+
+class PlaybackAudioCapture(
+ private val targetUid: Int,
+ private val onUnavailable: () -> Unit,
+) : JavaAudioDeviceModule.AudioBufferCallback, AutoCloseable {
+ private val record = AtomicReference()
+ private val closed = AtomicBoolean()
+ private val unavailable = AtomicBoolean()
+
+ fun start(projection: MediaProjection): Boolean {
+ if (closed.get()) return false
+ val created = try {
+ val configuration = AudioPlaybackCaptureConfiguration.Builder(projection)
+ .addMatchingUid(targetUid)
+ .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
+ .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
+ .addMatchingUsage(AudioAttributes.USAGE_GAME)
+ .build()
+ val minimum = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_MASK, AUDIO_FORMAT)
+ if (minimum <= 0) return fail()
+ AudioRecord.Builder()
+ .setAudioFormat(
+ AudioFormat.Builder()
+ .setEncoding(AUDIO_FORMAT)
+ .setSampleRate(SAMPLE_RATE)
+ .setChannelMask(CHANNEL_MASK)
+ .build(),
+ )
+ .setBufferSizeInBytes(maxOf(minimum * 2, FRAME_BYTES))
+ .setAudioPlaybackCaptureConfig(configuration)
+ .build()
+ } catch (_: Exception) {
+ return fail()
+ }
+ try {
+ created.startRecording()
+ if (created.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
+ created.release()
+ return fail()
+ }
+ } catch (_: Exception) {
+ created.release()
+ return fail()
+ }
+ if (closed.get() || !record.compareAndSet(null, created)) {
+ runCatching { created.stop() }
+ created.release()
+ return false
+ }
+ return true
+ }
+
+ override fun onBuffer(
+ buffer: ByteBuffer,
+ audioFormat: Int,
+ channelCount: Int,
+ sampleRate: Int,
+ bytesRead: Int,
+ captureTimeNs: Long,
+ ): Long {
+ if (
+ audioFormat != AUDIO_FORMAT || channelCount != CHANNEL_COUNT ||
+ sampleRate != SAMPLE_RATE || buffer.capacity() != FRAME_BYTES
+ ) {
+ fail()
+ silence(buffer)
+ return 0L
+ }
+ val active = record.get()
+ if (active == null) {
+ SystemClock.sleep(FRAME_DURATION_MS)
+ silence(buffer)
+ return 0L
+ }
+ buffer.clear()
+ val read = try {
+ active.read(buffer, buffer.capacity(), AudioRecord.READ_BLOCKING)
+ } catch (_: Exception) {
+ AudioRecord.ERROR_INVALID_OPERATION
+ }
+ if (read != buffer.capacity()) {
+ SystemClock.sleep(FRAME_DURATION_MS)
+ silence(buffer)
+ if (read < 0) fail()
+ }
+ buffer.clear()
+ return 0L
+ }
+
+ override fun close() {
+ closed.set(true)
+ record.getAndSet(null)?.let {
+ runCatching { it.stop() }
+ it.release()
+ }
+ }
+
+ private fun fail(): Boolean {
+ close()
+ if (unavailable.compareAndSet(false, true)) onUnavailable()
+ return false
+ }
+
+ private fun silence(buffer: ByteBuffer) {
+ buffer.clear()
+ while (buffer.hasRemaining()) buffer.put(0)
+ buffer.clear()
+ }
+
+ companion object {
+ const val SAMPLE_RATE = 48_000
+ private const val CHANNEL_COUNT = 1
+ private const val CHANNEL_MASK = AudioFormat.CHANNEL_IN_MONO
+ private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
+ private const val FRAME_BYTES = SAMPLE_RATE / 100 * CHANNEL_COUNT * 2
+ private const val FRAME_DURATION_MS = 10L
+ }
+}
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt
index ea70906..b8f6935 100644
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/ProjectionService.kt
@@ -1,17 +1,20 @@
package icu.bonfire.screener.sender
+import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
+import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Bundle
import android.os.IBinder
import android.os.ResultReceiver
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
class ProjectionService : Service() {
@@ -38,42 +41,75 @@ class ProjectionService : Service() {
return START_NOT_STICKY
}
receiver = intent.getParcelableExtra(EXTRA_RECEIVER, ResultReceiver::class.java)
+ val server = intent.getStringExtra(EXTRA_SERVER_URL)
+ val password = intent.getStringExtra(EXTRA_SITE_PASSWORD)
+ val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
+ val audioTargetUid = if (intent.hasExtra(EXTRA_AUDIO_TARGET_UID)) {
+ intent.getIntExtra(EXTRA_AUDIO_TARGET_UID, -1).takeIf { it >= 0 }
+ } else {
+ null
+ }
+ val audioTargetName = intent.getStringExtra(EXTRA_AUDIO_TARGET_NAME)?.take(80)
+ if (server == null || password == null || permission == null) {
+ finish("Screen sharing could not start")
+ return START_NOT_STICKY
+ }
+ if (
+ audioTargetUid != null &&
+ checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED
+ ) {
+ finish("Playback audio permission is required")
+ return START_NOT_STICKY
+ }
startForeground(
NOTIFICATION_ID,
notification("Starting screen sharing"),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
- val server = intent.getStringExtra(EXTRA_SERVER_URL)
- val password = intent.getStringExtra(EXTRA_SITE_PASSWORD)
- val permission = intent.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
- if (server == null || password == null || permission == null) {
- finish("Screen sharing could not start")
- return START_NOT_STICKY
- }
sendResult(RESULT_ACTIVE, "Starting")
schedule {
try {
- val engine = CaptureEngine.create(applicationContext, permission) {
- finish("The system stopped screen sharing")
- }
+ val audioUnavailable = AtomicBoolean()
+ val engine = CaptureEngine.create(
+ applicationContext,
+ permission,
+ audioTargetUid,
+ onProjectionStopped = { finish("The system stopped screen sharing") },
+ onAudioUnavailable = {
+ audioUnavailable.set(true)
+ if (state.get() != State.STOPPING) {
+ sendResult(RESULT_ACTIVE, "Playback audio unavailable; video remains active")
+ }
+ },
+ )
if (state.get() != State.STARTING) {
engine.close()
return@schedule
}
capture = engine
- val remote = RemoteSession(server, password, engine.factory, engine.track, worker, object : RemoteSession.Events {
- override fun onRoom(inviteUrl: String) {
- if (state.get() == State.STOPPING) return
- sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested", inviteUrl)
- getSystemService(NotificationManager::class.java)
- .notify(NOTIFICATION_ID, notification("Screen sharing is ready"))
- }
+ val remote = RemoteSession(
+ server, password, engine.factory, engine.track, engine.audioTrack, worker,
+ object : RemoteSession.Events {
+ override fun onRoom(inviteUrl: String) {
+ if (state.get() == State.STOPPING) return
+ val audio = when {
+ engine.audioTrack != null && !audioUnavailable.get() -> audioTargetName?.let {
+ "; playback requested: $it"
+ } ?: "; playback requested"
+ audioTargetUid != null -> "; playback audio unavailable"
+ else -> ""
+ }
+ sendResult(RESULT_ACTIVE, "Room ready; hardware codec requested$audio", inviteUrl)
+ getSystemService(NotificationManager::class.java)
+ .notify(NOTIFICATION_ID, notification("Screen sharing is ready"))
+ }
- override fun onStatus(message: String) {
- if (state.get() != State.STOPPING) sendResult(RESULT_ACTIVE, message)
- }
- override fun onFatal(message: String) = finish(message)
- })
+ override fun onStatus(message: String) {
+ if (state.get() != State.STOPPING) sendResult(RESULT_ACTIVE, message)
+ }
+ override fun onFatal(message: String) = finish(message)
+ },
+ )
session = remote
if (!state.compareAndSet(State.STARTING, State.RUNNING)) return@schedule
remote.start()
@@ -152,6 +188,8 @@ class ProjectionService : Service() {
const val EXTRA_SITE_PASSWORD = "sitePassword"
const val EXTRA_PERMISSION_DATA = "projectionPermission"
const val EXTRA_RECEIVER = "receiver"
+ const val EXTRA_AUDIO_TARGET_UID = "audioTargetUid"
+ const val EXTRA_AUDIO_TARGET_NAME = "audioTargetName"
const val RESULT_STATUS = "status"
const val RESULT_INVITE = "invite"
const val RESULT_STOPPED = 0
diff --git a/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt b/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt
index 0a2ead8..3b3eda9 100644
--- a/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt
+++ b/android/sender/app/src/main/java/icu/bonfire/screener/sender/RemoteSession.kt
@@ -17,6 +17,7 @@ import okhttp3.WebSocketListener
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.HttpUrl.Companion.toHttpUrl
+import org.webrtc.AudioTrack
import org.webrtc.PeerConnectionFactory
import org.webrtc.VideoTrack
import java.util.UUID
@@ -29,6 +30,7 @@ class RemoteSession(
private var sitePassword: String,
private val factory: PeerConnectionFactory,
private val track: VideoTrack,
+ private val audioTrack: AudioTrack?,
private val serial: Executor,
private val events: Events,
) : AutoCloseable {
@@ -207,7 +209,9 @@ class RemoteSession(
(peers.keys - desired).forEach { peers.remove(it)?.close() }
desired.filterNot(peers::containsKey).forEach { peerId ->
val config = iceConfig ?: return@forEach
- peers[peerId] = HostPeer(peerId, opaqueId(), config, factory, track, serial, ::send, events::onStatus).also {
+ peers[peerId] = HostPeer(
+ peerId, opaqueId(), config, factory, track, audioTrack, serial, ::send, events::onStatus,
+ ).also {
it.start()
}
}
diff --git a/android/sender/app/src/main/res/layout/activity_main.xml b/android/sender/app/src/main/res/layout/activity_main.xml
index fe1b2a3..68f64d2 100644
--- a/android/sender/app/src/main/res/layout/activity_main.xml
+++ b/android/sender/app/src/main/res/layout/activity_main.xml
@@ -39,6 +39,26 @@
android:maxLines="1"
android:saveEnabled="false" />
+
+
+
+
+
+