feat(android): add selected-app playback audio (#112)

Default-off selected-UID Android playback capture through the pinned WebRTC ADM callback, with no microphone/system-mix fallback and no server/Viewer wire change. Source compile/protocol acceptance and remaining device gates are recorded in the PR.
This commit is contained in:
AAA authored and GitHub committed 2026-08-21 06:17:01 +08:00
1 parent d31912fe79
commit 7ea0c4f1ea
15 files changed
+501 -82

No files matched your search

+17 -6
View File
@@ -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
@@ -4,6 +4,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:allowBackup="false"
@@ -2,10 +2,14 @@ package icu.bonfire.screener.sender
import android.content.Context
import android.content.Intent
import android.media.AudioFormat
import android.media.projection.MediaProjection
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.EglBase
import org.webrtc.HardwareVideoEncoderFactory
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnectionFactory
import org.webrtc.ScreenCapturerAndroid
import org.webrtc.SurfaceTextureHelper
@@ -14,21 +18,30 @@ import org.webrtc.VideoEncoder
import org.webrtc.VideoEncoderFactory
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import org.webrtc.audio.AudioProcessingOptions
import org.webrtc.audio.JavaAudioDeviceModule
import java.util.concurrent.atomic.AtomicBoolean
class CaptureEngine private constructor(
context: Context,
permissionData: Intent,
audioTargetUid: Int?,
onProjectionStopped: () -> 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)) {
@@ -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() }
@@ -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<out String>,
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<AudioTarget> {
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
}
}
@@ -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<AudioRecord?>()
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
}
}
@@ -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
@@ -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()
}
}
@@ -39,6 +39,26 @@
android:maxLines="1"
android:saveEnabled="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Playback audio target (optional)"
android:textColor="#34443E"
android:textSize="14sp" />
<Spinner
android:id="@+id/audio_target"
android:layout_width="match_parent"
android:layout_height="52dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Audio is requested from this app separately and may differ from the system screen choice. Apps that block playback capture remain silent."
android:textColor="#5A6863"
android:textSize="13sp" />
<Button
android:id="@+id/start_button"
android:layout_width="match_parent"
+1 -1
View File
@@ -24,7 +24,7 @@ Last updated: 2026-08-21
- Browser relays resend remote `MediaStreamTrack` values and re-encode at each hop; WebRTC does not guarantee a shared encoder across peer connections, so measure the cost.
- Keep experiments bounded: the standard representation sequence is below; native RTP relay, encoded-object striping, and FEC remain separate.
- ADR-0006's historical browser-bridge canary remains no-go; VP8 stays default. Browser H.264 rendered 298/299; Native WGC/MF rendered 203 with PID/LUID-correlated `VideoEncode`. Downloaded use, multi-viewer/endurance/public proof remain open.
- Mobile Web Host unsupported. Android 14+ sender is direct-child video-only; device, audio, rotation, and SFU remain open. iOS deferred; TV output is local-only P2.
- Mobile Web Host unsupported. Android 14+ source has video and default-off selected-UID playback audio; no mic/system fallback. Device A/V, rotation and SFU remain open. iOS deferred; TV output is P2.
- ADR-0005 accepts direct/peer UDP, bounded SFU roots, then optional selected-edge TURN. Production enables the one-shot selected-edge tuple after SFU; participant-wide TURN remains rejected and ordinary peer ICE stays STUN-only. No production SFU/TURN media canary has run; latest source has one local SFU/UDP functional proof and TURN remains unverified.
- The controller is process-enabled, not room-allowlisted: `PEER_ASSISTED_MEDIA=true` gives every normal room the same direct/peer -> SFU -> selected-edge path with per-room state. Room `1` is historical smoke. Preserve sticky P2P, mobile leaves and break-before-make; recovery gets one attempt per layer (ICE restart, same-parent rebuild, alternate peer, then SFU), never three identical retries.
- C+B quality reparenting is edge-local and cooldown-bound; no score, timer, or global parent penalty.
+72 -25
View File
@@ -238,34 +238,74 @@ only one post-SFU connectivity smoke. Correlate capture settings, negotiated cod
actual outbound/inbound bitrate, loss, jitter, concealment, jitter buffer, and
A/V playout timing using a distinguishable stereo fixture plus game/film audio.
## Deferred Android Application-Audio Candidate
## Android Application-Audio Source Slice (2026-08-21)
This is a P2 input for the later Android 14/API 34+ native sender, not a current
Web or Windows requirement. Android's `AudioPlaybackCapture` API can constrain
captured playback with `addMatchingUid()` when the target UID is already known,
or with `addMatchingUsage()` for eligible usages. The source player still must
use `USAGE_MEDIA`, `USAGE_GAME`, or `USAGE_UNKNOWN`, run in the same user
profile, and permit capture under its manifest/runtime/player policy. The capture
app also needs `RECORD_AUDIO` permission and an approved `MediaProjection`
session. The most restrictive source policy wins, so silence or refusal is a
valid result and must not trigger a whole-system fallback.
Android's official `AudioPlaybackCapture` contract requires `RECORD_AUDIO`, an
approved `MediaProjection`, the same user profile, a capturable source policy,
and player usage `USAGE_UNKNOWN`, `USAGE_MEDIA`, or `USAGE_GAME`. A builder may
combine `addMatchingUid()` and matching usages; Screener uses both so the source
cannot widen beyond the explicitly selected UID. The UI only lists launcher
targets whose UID maps to one package, because an Android UID may otherwise be
shared. Protected/opt-out apps and players without eligible active playback
usually produce silence rather than a read error. Silence is a valid bounded
result and never triggers microphone or whole-system fallback.
Android 14 QPR2's system `MediaProjection` picker can limit video to one selected
app window. However, the public API 34 result contract passes the consent result into
`getMediaProjection()` and documents only a projection grant; it does not
promise the capturing app the selected package or UID. It is therefore an
inference, not an API guarantee, that the video selection can be associated with
the UID required by `addMatchingUid()`. Screener must separately prove the
selected-app-video plus selected-app-audio pairing on target devices with an
independently established UID, or keep that mode unsupported. Usage-only
filtering is broader and cannot be relabeled as per-app isolation.
Android 14 QPR2's system picker can limit video to one selected app, but public
API 34 returns only the projection consent result. `MediaProjection` exposes
stop, resize and visibility callbacks, not the selected package or UID. Screener
therefore defaults audio to off and makes the audio target a separate explicit
choice before opening the system video picker. Its visible copy says the two
choices may differ. The app never calls usage-only capture and never labels this
as automatic same-app capture.
The bounded audio gate covers a known target UID, an unknown picker target,
`USAGE_GAME`/`USAGE_MEDIA`, source opt-out, no active render stream, process
restart, voice/notification leakage, secure/protected video, rotation, lock-stop,
thermal load, one/two Web viewers, and A/V sync. Missing audio remains a visible
silent/unsupported state. PID/UID/package, source labels, raw audio, and device
identity stay local and are not retained or uploaded.
The fixed `io.github.webrtc-sdk:android:144.7559.12` AAR exposes the required
public extension points without a fork or JNI: `PeerConnectionFactory.Builder`
has `setAudioDeviceModule(AudioDeviceModule)`, `JavaAudioDeviceModule.Builder`
has `setAudioBufferCallback(AudioBufferCallback)`, and the module has
`setAudioRecordEnabled(false)`. The callback receives the ADM's 10 ms direct
buffer. Inspection of that exact artifact shows that a disabled platform input
is zero-filled and still passed through the callback before native delivery, so
one blocking playback `AudioRecord` can pace and replace the buffer. Screener
uses PCM16, 48 kHz mono, raw audio processing, one `AudioSource`/`AudioTrack`, and
attaches that track to no more than the two controller-authorized direct
children. It changes no server/Viewer wire and leaves SFU/selected-edge TURN as
the existing bounded failure. This proves one capture source/track, not one
physical Opus encoder across PeerConnections.
The inspected AAR SHA-256 is
`D1564A43A85D0687DB51862A590263C6764F9AC8F87E7960B1E9C9F222B898AA`; its
`classes.jar` SHA-256 is
`D20DFD0C0BBEDCCB46354174829B0315E6922D05779291A60DACEA826098CDA4`, and tag
`v144.7559.12` resolves to `500cadca96fa4be0fb5951c046fd611be181490c`.
The Maven POM records BSD-3-Clause for the artifact; the packaging repository is
MIT and bundled upstream libwebrtc retains BSD/PATENTS terms. LiveKit's
Apache-2.0 `ScreenAudioCapturer` was studied as a mature use of the same callback
and playback-capture API; no implementation was copied.
Official playback-capture documentation requires `RECORD_AUDIO` plus an approved
`MediaProjection` token. Android 14's FGS documentation assigns
`mediaProjection` to content captured through `MediaProjection`, while the
`microphone` type is described as continuing microphone capture. Screener uses
only `FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION`; it does not request
`FOREGROUND_SERVICE_MICROPHONE` or declare a microphone service type. There is a
retained implementation-evidence conflict: LiveKit's pinned playback capturer
states that background screen audio needs a microphone-typed FGS or returns no
audio, while also showing that its ADM microphone may be disabled. Android's
official playback-capture and API 34 FGS pages do not state that playback capture
needs that additional type. This source slice follows the narrower official
capability boundary; switching to the selected app must therefore verify
background audio on-device before any support claim. A silent result does not
authorize enabling microphone capture or adding the extra FGS capability without
a separate permission-policy decision. The ADM microphone `AudioRecord` remains
disabled in every case.
The source slice passed `:protocol:test` and `:app:assembleDebug`; no device or
audible-media claim follows. Track creation/status means only “selected UID
playback requested.” A silence detector is P2 and does not expand this slice.
Device proof still covers eligible and opt-out apps, protected content, no
active render stream, voice/notification non-leakage, process restart, lock/stop,
one/two Web Viewers and A/V synchronization. UID/package labels stay local and
raw audio is never retained or uploaded outside the WebRTC media track.
## Revisit Gates
@@ -308,4 +348,11 @@ Add a user-facing audio setting only when all of these are true:
- [Android `AudioPlaybackCaptureConfiguration.Builder`](https://developer.android.com/reference/android/media/AudioPlaybackCaptureConfiguration.Builder)
- [Android `MediaProjectionManager`](https://developer.android.com/reference/android/media/projection/MediaProjectionManager)
- [Android media projection](https://developer.android.com/media/grow/media-projection)
- [Android foreground-service types](https://developer.android.com/develop/background-work/services/fgs/service-types)
- [Android 14 foreground-service type requirement](https://developer.android.com/about/versions/14/changes/fgs-types-required)
- [Android secure-window capture boundary](https://developer.android.com/reference/android/view/WindowManager.LayoutParams#FLAG_SECURE)
- [`webrtc-sdk/android` v144.7559.12](https://github.com/webrtc-sdk/android/tree/v144.7559.12)
- [Maven Central `io.github.webrtc-sdk:android:144.7559.12`](https://central.sonatype.com/artifact/io.github.webrtc-sdk/android/144.7559.12)
- [LiveKit Android `ScreenAudioCapturer` at inspected commit](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt)
- [LiveKit Android screen-audio foreground-service example](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/examples/screenshare-audio/src/main/java/io/livekit/android/example/screenshareaudio/MainViewModel.kt)
- [LiveKit Android Apache-2.0 license](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/LICENSE)
+29 -13
View File
@@ -151,8 +151,8 @@ experience and cannot be made invisible.
The bounded Android data path is:
```text
MediaProjection -> libwebrtc ScreenCapturerAndroid
-> HardwareVideoEncoderFactory (VP8/H.264 only, no software fallback)
MediaProjection -> ScreenCapturerAndroid -> hardware VP8/H.264 --.
explicit UID -> AudioPlaybackCapture -> ADM callback ---------+
-> existing site access / room / WSS / peer-assisted direct assignment
-> at most two unmodified Web viewers
```
@@ -160,18 +160,27 @@ MediaProjection -> libwebrtc ScreenCapturerAndroid
The slice uses the public libwebrtc Android API rather than hand-building capture,
RTP, pacing, congestion control, or SRTP. `DefaultVideoEncoderFactory` is excluded
because it composes hardware with software fallback; a filtered
`HardwareVideoEncoderFactory` exposes only VP8/H.264. It adds no mobile-only
topology, audio, `LOW`, AV1, codec ladder, third downstream edge, SFU publisher,
selected-edge TURN, or software fallback. Direct children are authoritative by
route revision and connection generation. A requested SFU publication returns
`HardwareVideoEncoderFactory` exposes only VP8/H.264. Optional playback audio is
default-off and source-only: the user separately selects one single-package UID,
the capture matches that UID plus eligible game/media usages, and the fixed
webrtc-sdk public ADM callback injects 48 kHz mono PCM16 while its microphone
`AudioRecord` is disabled. One audio source/track is attached to the same maximum
of two direct children; this does not prove a shared physical Opus encoder. It
adds no mobile-only topology, `LOW`, AV1, codec ladder, third downstream edge,
SFU publisher, selected-edge TURN, microphone/system-mix fallback, or software
fallback. Direct children are authoritative by route revision and connection
generation. A requested SFU publication returns
one bounded `route-failed`; it is not reported as supported.
The first functional target is 720p30 at a 3 Mbps ceiling. Codec name and Android
hardware metadata are admission signals, not performance proof. Once the source
slice closes, one device/one Viewer smoke checks consent, selected encoder, first
frame and cleanup. Rotation hot-resize, application audio, network reselection,
thermal/power and multi-Viewer matrices follow later; they do not block source
landing and cannot silently enable software encoding. Network changes reuse the
frame and cleanup. The audio source compile is closed, but eligible/opt-out
playback, silence, background service behavior and A/V sync still require one
device; track creation only means selected-UID playback was requested. Rotation
hot-resize, network reselection, thermal/power and multi-Viewer matrices follow
later; they do not block source landing and cannot silently enable software
encoding. Network changes reuse the
existing per-edge WebRTC recovery contract rather than restarting capture or
creating a new transport.
@@ -182,9 +191,11 @@ session. Restoring service status is a later P2 lifecycle task, not a media-path
requirement for this source slice.
The pinned `webrtc-sdk/android` packaging project is MIT-licensed and bundles
upstream libwebrtc under its BSD-style license and PATENTS terms. OkHttp and
kotlinx.serialization are Apache-2.0. The slice copies no implementation from
LiveKit or another sender. A future distributed APK must retain the applicable
upstream libwebrtc under its BSD-style license and PATENTS terms; the Maven
artifact POM records BSD-3-Clause. LiveKit's Apache-2.0 `ScreenAudioCapturer` was
studied as a reference for the public ADM callback and playback-capture lifecycle,
but no implementation was copied. OkHttp and kotlinx.serialization are
Apache-2.0. A future distributed APK must retain the applicable
third-party notices and still waits on Screener's project-license decision.
OkHttp 5.3.0 resolves Kotlin stdlib 2.2.21. Android's current compatibility
@@ -598,13 +609,18 @@ SFU/UDP fails and needs its own bounded transport gate.
- [Android media projection](https://developer.android.com/media/grow/media-projection)
- [Android 14 app screen sharing](https://developer.android.com/about/versions/14/features/app-screen-sharing)
- [Android media-projection foreground service](https://developer.android.com/develop/background-work/services/fgs/service-types#media-projection)
- [Android audio-playback capture](https://developer.android.com/media/platform/av-capture)
- [Android `AudioPlaybackCaptureConfiguration`](https://developer.android.com/reference/android/media/AudioPlaybackCaptureConfiguration)
- [Android 14 foreground-service type requirement](https://developer.android.com/about/versions/14/changes/fgs-types-required)
- [Android `MediaCodecInfo`](https://developer.android.com/reference/android/media/MediaCodecInfo)
- [Android `MediaCodecList`](https://developer.android.com/reference/android/media/MediaCodecList)
- [libwebrtc `ScreenCapturerAndroid`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/ScreenCapturerAndroid.java)
- [libwebrtc `DefaultVideoEncoderFactory`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/DefaultVideoEncoderFactory.java)
- [libwebrtc `HardwareVideoEncoderFactory`](https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/api/org/webrtc/HardwareVideoEncoderFactory.java)
- [`webrtc-sdk/android`](https://github.com/webrtc-sdk/android)
- [`webrtc-sdk/android` v144.7559.12](https://github.com/webrtc-sdk/android/tree/v144.7559.12)
- [`webrtc-sdk/android` license](https://github.com/webrtc-sdk/android/blob/main/LICENSE)
- [LiveKit Android `ScreenAudioCapturer` reference](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt)
- [LiveKit Android license](https://github.com/livekit/client-sdk-android/blob/4fdea28d8bd4b7053d7e5f0df50527f2c51fd38f/LICENSE)
- [libwebrtc license](https://webrtc.googlesource.com/src/+/refs/heads/main/LICENSE)
- [OkHttp license](https://github.com/square/okhttp/blob/master/LICENSE.txt)
- [kotlinx.serialization license](https://github.com/Kotlin/kotlinx.serialization/blob/master/LICENSE.txt)
+1 -1
View File
@@ -43,7 +43,7 @@ Flagship; parallel; min run/smoke/rollback; benchmark later. Active UI/config/lo
## Unverified Boundaries
- The final deployment proves process/config health, not a retained SFU frame, retry/failback, selected UDP pair, admission rescue, edge cap, quality, or resource deltas; game-share load/blur remains unclassified.
- Mobile Web Host unsupported; Viewer leaf-only. Android 14+ direct-child source passed six protocol tests and `assembleDebug`; GitHub compile is billing-blocked. Device media, SFU, audio, and rotation remain open. iOS deferred; TV output is local-only P2.
- Mobile Web Host unsupported; Viewer leaf-only. Android 14+ direct source/default-off selected-UID audio passed six protocol tests/`assembleDebug`. No device/audible claim; mediaProjection-only background, opt-out/silence/A-V, SFU/rotation remain open. iOS deferred; TV output P2.
- Silent partitions can wait 30 to 60 seconds for heartbeat detection before the default 5-second grace; this remains unverified.
- The former release reportedly sustained bandwidth-limited blur on a capable LAN; Host refresh recovered while Viewer refresh did not. The new deployment has not yet reproduced or cleared it.
- ADR-0006 has one Viewer proof; two-edge/FIFO, hardware, endurance, public/downloaded-package, and browser-diversity proof remain open.
+22 -6
View File
@@ -215,8 +215,8 @@ Android 14/API 34+ 作为不阻断 Windows 主线的并行 P1 首片,只做一
`ScreenCapturerAndroid` -> 仅暴露 VP8/H.264 的 `HardwareVideoEncoderFactory` -> 当前
site access/room/WSS/peer-assisted Host direct-child wire -> 一至两名未修改 Web
Viewer。不能使用含 software fallback 的 `DefaultVideoEncoderFactory`;找不到或创建
不了硬件 encoder 时明确失败。首片固定 720p30、3 Mbps `HIGH`,不增加移动端专属
拓扑、音频、`LOW`、AV1、codec ladder、第三条 Host 下游边或跨平台 framework。
不了硬件 encoder 时明确失败。视频固定 720p30、3 Mbps `HIGH`,不增加移动端专属
拓扑、`LOW`、AV1、codec ladder、第三条 Host 下游边或跨平台 framework。
Android Host 以 authenticated snapshot 的 `routeRevision`/`routeAssignment` 为权威,
只为 `directChildPeerIds` 建立至多两条含 `targetPeerId` 的 STUN-only PeerConnection,
@@ -228,10 +228,26 @@ direct `route-update(active)` 先 reconcile child,再发送 `route-ready(activ
不能把本地旁路当作旗舰验收结论。
Android 的逐 session 授权、系统指示、旋转/尺寸回调、锁屏/撤销终止与资源释放
都是产品行为,不能隐藏或用重启 capture 掩盖 transport 问题。应用音频可按已知
UID 或 usage 过滤,但 Android 14 QPR2+ 单应用 picker 未承诺返回目标 UID;“所选应用画面 +
同一应用音频”必须单独实测,来源策略或 secure/protected content 可产生静音、
空白或拒绝,不能自动扩大为整机捕获。API 证据与矩阵由[原生 sender 调研](./research/native-shared-encode-sender.md)和[音频调研](./research/browser-screen-audio-quality.md)持有。
都是产品行为,不能隐藏或用重启 capture 掩盖 transport 问题。播放音频是默认关闭的
source extension:用户先从本应用可见的 launcher targets 中单独选择一个 UID;共享
UID 对应多个 packages 时不提供该目标。只有启用时才请求 `RECORD_AUDIO`,并用同一
`MediaProjection` 构造 `AudioPlaybackCaptureConfiguration`,同时匹配该 UID 与
`USAGE_UNKNOWN`/`MEDIA`/`GAME`。固定 `webrtc-sdk/android` 的公开
`JavaAudioDeviceModule.AudioBufferCallback` 把 48 kHz mono PCM16 注入一个 ADM;
`setAudioRecordEnabled(false)` 关闭 ADM 的平台麦克风输入,`AudioProcessingOptions.raw()`
避免语音处理。一个 `AudioSource`/`AudioTrack` 附加到当前至多两条 direct-child PC,
不修改 Viewer、服务端或路由 wire,也不为 SFU/selected-edge TURN 增加旁路。
Android 公开 API 34 的系统单应用 picker 不返回所选 package/UID,所以画面和音频是
两次独立选择;UI 必须直接说明两者可能不同,不能称为自动同应用。目标应用 opt-out、
protected content 或没有合格播放通常会让 `AudioRecord` 持续返回静音而非 read error;
track 存在只能显示“已请求选定 UID 的播放音频”,不能宣称实际有声。初始化/read
失败则关闭音频并保留视频,绝不回退到麦克风或全系统 mix。Android 官方播放捕获
契约要求 `RECORD_AUDIO` 和 `MediaProjection` token;服务只声明并启动
`mediaProjection` 类型,不申请描述为继续麦克风捕获的额外 `microphone` FGS 能力,
且 ADM 麦克风保持禁用。静音检测是 P2,真机 A/V sync、后台行为和设备差异仍是验收边界。
API 证据由[原生 sender 调研](./research/native-shared-encode-sender.md)和
[音频调研](./research/browser-screen-audio-quality.md)持有。
iOS 当前不建设 ReplayKit Broadcast Upload Extension 兼容栈。待 iOS 27 与
ScreenCaptureKit SDK final/stable 后,只做 system picker -> VideoToolbox
+3 -1
View File
@@ -42,7 +42,8 @@
- [ ] 私密房间双入口:private room 接受现有 `/r/{code}#v={grant}` 合法邀请直接进入,或“站点访问 + 房间号 + 该房间观看密码”。没有 grant 的 `/join` 或 `/r/{code}` 先显示中性的站点访问验证,通过后才显示中性的房间密码输入。Host 可设置、轮换或移除房间观看密码。密码接受 1 至 64 个可见 ASCII 字符;异步 scrypt 全局最多执行 2 个、等待 16 个派生。站点口令和房间密码都不得进入 URL/query/storage/log/SQLite;不存在、错误密码、队满和正确密码后的满房都返回通用结果。
- [ ] 统一自动路由:旗舰部署开启 `PEER_ASSISTED_MEDIA` 后,所有正常房间都进入同一个 direct/peer -> SFU -> selected-edge controller;每个房间独立保存 assignment/session/revision/connection generation 和容量状态。`PEER_ASSISTED_ROOM_IDS` 已退役,room `1` 只作为历史 smoke fixture,不能作为功能开关。未启用总开关的开发/轻量部署仍可保留 ordinary P2P;普通 peer 始终 STUN-only。selected-edge TURN 只能复用当前 controller 选中的异常 edge,不能向普通 peer edge 预发 candidates。该能力不增加 UI、用户选择、百分比发布或第二套路由器。
- [ ] Android 原生分享端首片:Android 14/API 34+ 通过系统 `MediaProjection` picker、`mediaProjection` foreground service 和硬件-only libwebrtc VP8/H.264 encoder 发送一份 720p30 `HIGH` 视频;无硬件 encoder 时明确失败,不静默使用软件编码。它必须复用旗舰 `screener-v2` 的 site access、room、WSS 与 peer-assisted Host direct-child assignment,按权威 route revision/connection generation 维护至多两条 STUN-only downstream edge;direct active route 回报 `route-ready`,SFU publication assignment 在本首片中回报有界 `route-failed`。Viewer 与服务端媒体协议不变。不实现音频、`LOW`、AV1、codec ladder、第三条 Host edge、selected-edge TURN、SFU SDK、旋转热更新、后台重建或跨平台 framework;系统撤销/停止必须在串行 teardown 中先对已建立的房间发送一次 `abandon-room`、再正常关闭信令并立即释放 capture、PeerConnection 和前台服务,不等待 room-close ACK;尚未建立信令的 provisional room 仍由 300 秒上限回收。
- [ ] Android 原生分享端首片:Android 14/API 34+ 通过系统 `MediaProjection` picker、`mediaProjection` foreground service 和硬件-only libwebrtc VP8/H.264 encoder 发送一份 720p30 `HIGH` 视频;无硬件 encoder 时明确失败,不静默使用软件编码。它必须复用旗舰 `screener-v2` 的 site access、room、WSS 与 peer-assisted Host direct-child assignment,按权威 route revision/connection generation 维护至多两条 STUN-only downstream edge;direct active route 回报 `route-ready`,SFU publication assignment 在本首片中回报有界 `route-failed`。Viewer 与服务端媒体协议不变。不实现 `LOW`、AV1、codec ladder、第三条 Host edge、selected-edge TURN、SFU SDK、旋转热更新、后台重建或跨平台 framework;系统撤销/停止必须在串行 teardown 中先对已建立的房间发送一次 `abandon-room`、再正常关闭信令并立即释放 capture、PeerConnection 和前台服务,不等待 room-close ACK;尚未建立信令的 provisional room 仍由 300 秒上限回收。
- [ ] Android 指定应用播放音频首片:音频默认关闭,只有用户在系统画面 picker 之前另行明确选择一个 launcher 应用且授予 `RECORD_AUDIO` 后才启用。目标必须是 UID 只映射到一个 package 的应用;捕获配置同时使用 `addMatchingUid()` 与 `USAGE_UNKNOWN`/`MEDIA`/`GAME`,ADM 自带麦克风 `AudioRecord` 必须关闭,初始化/read 失败、应用禁止 capture、protected content 或没有合格播放都只能保持静音/显示 unavailable,不得扩大为麦克风或全系统混音。Android 公开 API 不返回系统 picker 所选画面的 UID,因此 UI 必须说明音频目标单独选择、可能与画面不同,不能称为自动同应用。一个 ADM audio source/track 只附加到当前至多两名 direct children;Viewer、服务端 wire、SFU/selected-edge TURN 有界失败均不变。track 存在只表示已请求选定 UID 播放音频,不证明实际有声;静音检测、真机 A/V 同步和路由矩阵留待后续。
- [ ] 质量控制扩展:推荐档位仍是默认入口;折叠高级区只允许 720p/1080p/1440p 分辨率 ceiling、15 至 60 的整数 FPS ceiling、2 至 12 Mbps 视频码率 ceiling,以及清晰/平衡/流畅三种偏好。直播中显式应用后才生效,避免滑杆产生连续控制流量。当前 Web 发送端不提供音频声道、采样率、Opus 码率、stereo、DTX 或 FEC 控件:display capture 未把声道和采样率定义为可移植控制,标准音频 `maxBitrate` 只是可能导致更低质量或停播的发送上限,其他项依赖 codec/fmtp 协商而没有可移植 sender setter。除非真实音频矩阵证明用户问题、标准 API 能应用并读回且实际结果可独立观测,否则不得为这些控件新增应用层 SDP munging 或用默认值制造伪控制。实际质量持续低于所选上限时必须显示可解释的限制原因;在真实拥塞、编码器和接收端证据完成前,不把任一组合称为“完美平衡”。
- [ ] 本地预览性能候选:在相同动态场景中 A/B 测量 Host 动态预览开/关的 CPU、GPU、encode time 和实际 FPS。只有稳定收益且捕获、编码和远端画面不受影响时,才允许直播开始后默认显示静止缩略图或隐藏动态预览并提供按需展开;未测前不宣称收益。
@@ -126,6 +127,7 @@
- Given 用户在本机设置显示名,When 以 Host 或 Viewer 加入房间,Then 所有在线参与者看到同一份服务端权威 roster、对应房间标题和“名称 + room-scoped peer 后缀”;刷新后本机名称仍在,清除站点数据后恢复未命名默认值,数据库中不存在显示名或成员记录,展示身份不参与授权或路由。
- Given 手机从 Wi-Fi 切换到蜂窝网络或页面从后台恢复,When 原候选对失效,Then 客户端执行 ICE restart 或重建连接,并显示恢复进度而不是永久黑屏。
- Given Android 14+ 用户批准系统屏幕分享且至少一个受支持硬件 VP8/H.264 encoder 可创建,When peer-assisted Host 收到至多两个 direct-child assignment,Then 应用只向这些 `targetPeerId` 建立 generation-bound STUN-only WebRTC 视频边,并让未修改 Web Viewer 解码;任何 route revision、connection identity 或 target 不匹配的信令均 fail closed。Given assignment 要求 SFU publication、硬件 encoder 不可用或系统撤销 projection,Then 本首片分别发送有界 route failure 或停止清理,不切软件编码、不创建第三条边、不切换到自定义媒体协议。
- Given Android 用户保持默认音频关闭,When 开始分享,Then 应用不请求 `RECORD_AUDIO` 且不创建 playback `AudioRecord`/audio track。Given 用户明确选择一个单-package UID 音频目标并授予权限,When 开始分享,Then 一份以该 UID 和允许 usage 过滤的 playback PCM source/track 最多附加到两条 direct-child PeerConnection,ADM 麦克风采集保持关闭;系统画面 picker 与音频选择不一致时不扩大捕获,目标拒绝 capture 或没有合格播放时允许静音且不得把 track 存在显示为有声成功。
- Given Viewer 尝试把直播输出到电视,When 浏览器/系统提供已验证的 Remote Playback、AirPlay 或屏幕镜像能力,Then 该动作只改变本机输出设备,现有 upstream PeerConnection、TURN/SFU/peer route 和其他参与者保持不变。Given live `srcObject` 未经目标设备证明或 API 缺失,Then 产品不显示伪可用 Cast/AirPlay 按钮,也不创建自有 receiver 或转码服务器。
### 一对多与带宽保护