mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
snap
This commit is contained in:
@@ -13,6 +13,12 @@ public final class CastConfig {
|
|||||||
/** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */
|
/** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */
|
||||||
public static final int VIDEO_I_FRAME_INTERVAL = 1;
|
public static final int VIDEO_I_FRAME_INTERVAL = 1;
|
||||||
|
|
||||||
|
/** Request sync frames on this interval when the screen is static (Mediatek stall workaround). */
|
||||||
|
public static final long KEYFRAME_KEEPALIVE_MS = 2_000;
|
||||||
|
|
||||||
|
/** P-frames smaller than this are treated as empty and not sent (unless keepalive IDR). */
|
||||||
|
public static final int MIN_SIGNIFICANT_FRAME_BYTES = 400;
|
||||||
|
|
||||||
public static final int AUDIO_SAMPLE_RATE = 44100;
|
public static final int AUDIO_SAMPLE_RATE = 44100;
|
||||||
public static final int AUDIO_CHANNEL_COUNT = 2;
|
public static final int AUDIO_CHANNEL_COUNT = 2;
|
||||||
public static final int AUDIO_BITRATE = 128_000;
|
public static final int AUDIO_BITRATE = 128_000;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.foxx.androidcast;
|
|||||||
|
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
|
||||||
/** Computes encoder VirtualDisplay size from display metrics and user settings. */
|
/** Computes encoder VirtualDisplay size from display metrics and user settings. */
|
||||||
public final class CastResolution {
|
public final class CastResolution {
|
||||||
private CastResolution() {}
|
private CastResolution() {}
|
||||||
@@ -19,26 +21,53 @@ public final class CastResolution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Size compute(DisplayMetrics display, CastSettings settings) {
|
public static Size compute(DisplayMetrics display, CastSettings settings) {
|
||||||
|
return compute(display, settings, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Size compute(DisplayMetrics display, CastSettings settings, NetworkStatsSnapshot peer) {
|
||||||
int displayW = display.widthPixels & ~1;
|
int displayW = display.widthPixels & ~1;
|
||||||
int displayH = display.heightPixels & ~1;
|
int displayH = display.heightPixels & ~1;
|
||||||
float scale = settings.getResolutionScale();
|
float scale = settings.getResolutionScale();
|
||||||
int encW = Math.max(2, ((int) (displayW * scale)) & ~1);
|
int encW = even(Math.max(2, (int) (displayW * scale)));
|
||||||
int encH = Math.max(2, ((int) (displayH * scale)) & ~1);
|
int encH = even(Math.max(2, (int) (displayH * scale)));
|
||||||
|
|
||||||
int maxH = settings.getMaxHeight();
|
CastTuningEngine.CaptureLimits limits = CastTuningEngine.resolveCaptureLimits(settings, peer);
|
||||||
|
int maxH = limits.maxHeight > 0 ? limits.maxHeight : settings.getMaxHeight();
|
||||||
|
int maxW = limits.maxWidth > 0 ? limits.maxWidth : settings.getMaxWidth();
|
||||||
|
|
||||||
|
if (maxH > 0 && maxW == 0) {
|
||||||
|
int[] capped = capLongEdge(encW, encH, maxH);
|
||||||
|
encW = capped[0];
|
||||||
|
encH = capped[1];
|
||||||
|
} else {
|
||||||
if (maxH > 0 && encH > maxH) {
|
if (maxH > 0 && encH > maxH) {
|
||||||
float ratio = (float) maxH / encH;
|
float ratio = (float) maxH / encH;
|
||||||
encH = maxH & ~1;
|
encH = even(maxH);
|
||||||
encW = Math.max(2, ((int) (encW * ratio)) & ~1);
|
encW = even(Math.max(2, (int) (encW * ratio)));
|
||||||
}
|
}
|
||||||
int maxW = settings.getMaxWidth();
|
|
||||||
if (maxW > 0 && encW > maxW) {
|
if (maxW > 0 && encW > maxW) {
|
||||||
float ratio = (float) maxW / encW;
|
float ratio = (float) maxW / encW;
|
||||||
encW = maxW & ~1;
|
encW = even(maxW);
|
||||||
encH = Math.max(2, ((int) (encH * ratio)) & ~1);
|
encH = even(Math.max(2, (int) (encH * ratio)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int density = Math.max(120, (int) (display.densityDpi * scale));
|
int density = Math.max(120, (int) (display.densityDpi * scale));
|
||||||
return new Size(encW, encH, density);
|
return new Size(encW, encH, density);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Caps the longer side (orientation-independent 720p height-cap mode). */
|
||||||
|
private static int[] capLongEdge(int width, int height, int maxLong) {
|
||||||
|
int longEdge = Math.max(width, height);
|
||||||
|
if (longEdge <= maxLong) {
|
||||||
|
return new int[] {width, height};
|
||||||
|
}
|
||||||
|
float ratio = (float) maxLong / longEdge;
|
||||||
|
return new int[] {even(Math.max(2, (int) (width * ratio))),
|
||||||
|
even(Math.max(2, (int) (height * ratio)))};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int even(int v) {
|
||||||
|
return Math.max(2, v & ~1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.io.Serializable;
|
|||||||
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
|
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
|
||||||
public class CastSettings implements Serializable {
|
public class CastSettings implements Serializable {
|
||||||
public static final String EXTRA = "cast_settings";
|
public static final String EXTRA = "cast_settings";
|
||||||
|
public static final int SETTINGS_VERSION = 2;
|
||||||
|
|
||||||
public enum Quality {
|
public enum Quality {
|
||||||
LOW(1_500_000, 24),
|
LOW(1_500_000, 24),
|
||||||
@@ -20,12 +21,34 @@ public class CastSettings implements Serializable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capture scale relative to physical display (1.0 = native). */
|
public enum VideoCodec {
|
||||||
|
AUTO, H264, H265, PASSTHROUGH
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AdjustmentPreset {
|
||||||
|
AUTO, NONE, QUALITY, SPEED, PERFORMANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BitrateMode {
|
||||||
|
AUTO, NONE, QUALITY, SPEED, BALANCED, ROBUST_LESS, ESTIMATED
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NetworkAdoption {
|
||||||
|
AUTO, QUALITY, SPEED, ESTIMATED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture size. {@link #HD_720P} caps height only (width follows aspect — e.g. 1532×720).
|
||||||
|
* {@link #FIXED_720P} fits inside 1280×720 box.
|
||||||
|
*/
|
||||||
public enum Resolution {
|
public enum Resolution {
|
||||||
FULL(1.0f, 0, 0),
|
FULL(1.0f, 0, 0),
|
||||||
THREE_QUARTERS(0.75f, 0, 0),
|
THREE_QUARTERS(0.75f, 0, 0),
|
||||||
HALF(0.5f, 0, 0),
|
HALF(0.5f, 0, 0),
|
||||||
HD_720P(1.0f, 720, 1280);
|
HD_720P(1.0f, 720, 0),
|
||||||
|
FIXED_1080P(0f, 1080, 1920),
|
||||||
|
FIXED_720P(0f, 720, 1280),
|
||||||
|
ADAPTIVE(1.0f, 0, 0);
|
||||||
|
|
||||||
public final float scale;
|
public final float scale;
|
||||||
public final int maxHeight;
|
public final int maxHeight;
|
||||||
@@ -42,6 +65,12 @@ public class CastSettings implements Serializable {
|
|||||||
private Quality quality = Quality.MEDIUM;
|
private Quality quality = Quality.MEDIUM;
|
||||||
private Resolution resolution = Resolution.HD_720P;
|
private Resolution resolution = Resolution.HD_720P;
|
||||||
private boolean audioEnabled;
|
private boolean audioEnabled;
|
||||||
|
private VideoCodec videoCodec = VideoCodec.AUTO;
|
||||||
|
private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO;
|
||||||
|
private BitrateMode bitrateMode = BitrateMode.AUTO;
|
||||||
|
private NetworkAdoption networkAdoption = NetworkAdoption.AUTO;
|
||||||
|
/** Filled after codec handshake (e.g. video/avc). */
|
||||||
|
private transient String negotiatedVideoMime;
|
||||||
|
|
||||||
public CastSettings() {
|
public CastSettings() {
|
||||||
PlatformCastSupport.applyDefaultAudioSetting(this);
|
PlatformCastSupport.applyDefaultAudioSetting(this);
|
||||||
@@ -79,11 +108,43 @@ public class CastSettings implements Serializable {
|
|||||||
this.audioEnabled = audioEnabled;
|
this.audioEnabled = audioEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raw user toggle; may be true on old platforms but {@link #isAudioEnabled()} stays false. */
|
|
||||||
public boolean isAudioRequested() {
|
public boolean isAudioRequested() {
|
||||||
return audioEnabled;
|
return audioEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public VideoCodec getVideoCodec() {
|
||||||
|
return videoCodec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVideoCodec(VideoCodec videoCodec) {
|
||||||
|
this.videoCodec = videoCodec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdjustmentPreset getAdjustmentPreset() {
|
||||||
|
return adjustmentPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAdjustmentPreset(AdjustmentPreset adjustmentPreset) {
|
||||||
|
this.adjustmentPreset = adjustmentPreset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BitrateMode getBitrateMode() {
|
||||||
|
return bitrateMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBitrateMode(BitrateMode bitrateMode) {
|
||||||
|
this.bitrateMode = bitrateMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NetworkAdoption getNetworkAdoption() {
|
||||||
|
return networkAdoption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNetworkAdoption(NetworkAdoption networkAdoption) {
|
||||||
|
this.networkAdoption = networkAdoption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy: raw quality bitrate. Prefer {@link CastTuningEngine#resolve}. */
|
||||||
public int getVideoBitrate() {
|
public int getVideoBitrate() {
|
||||||
return quality.videoBitrate;
|
return quality.videoBitrate;
|
||||||
}
|
}
|
||||||
@@ -103,4 +164,16 @@ public class CastSettings implements Serializable {
|
|||||||
public int getMaxWidth() {
|
public int getMaxWidth() {
|
||||||
return resolution.maxWidth;
|
return resolution.maxWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isPassthrough() {
|
||||||
|
return videoCodec == VideoCodec.PASSTHROUGH;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNegotiatedVideoMime() {
|
||||||
|
return negotiatedVideoMime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNegotiatedVideoMime(String negotiatedVideoMime) {
|
||||||
|
this.negotiatedVideoMime = negotiatedVideoMime;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
app/src/main/java/com/foxx/androidcast/CastTuningEngine.java
Normal file
173
app/src/main/java/com/foxx/androidcast/CastTuningEngine.java
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package com.foxx.androidcast;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
|
||||||
|
/** Resolves AUTO presets into concrete encoder/network/capture parameters. */
|
||||||
|
public final class CastTuningEngine {
|
||||||
|
private CastTuningEngine() {}
|
||||||
|
|
||||||
|
public static final class EffectiveParams {
|
||||||
|
public final int videoBitrate;
|
||||||
|
public final int videoFps;
|
||||||
|
public final int keyframeIntervalSec;
|
||||||
|
public final boolean requestLowLatency;
|
||||||
|
public final boolean skipTinyPFrames;
|
||||||
|
|
||||||
|
EffectiveParams(int videoBitrate, int videoFps, int keyframeIntervalSec,
|
||||||
|
boolean requestLowLatency, boolean skipTinyPFrames) {
|
||||||
|
this.videoBitrate = videoBitrate;
|
||||||
|
this.videoFps = videoFps;
|
||||||
|
this.keyframeIntervalSec = keyframeIntervalSec;
|
||||||
|
this.requestLowLatency = requestLowLatency;
|
||||||
|
this.skipTinyPFrames = skipTinyPFrames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class CaptureLimits {
|
||||||
|
public final int maxWidth;
|
||||||
|
public final int maxHeight;
|
||||||
|
|
||||||
|
public CaptureLimits(int maxWidth, int maxHeight) {
|
||||||
|
this.maxWidth = maxWidth;
|
||||||
|
this.maxHeight = maxHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CaptureLimits resolveCaptureLimits(CastSettings settings, NetworkStatsSnapshot peer) {
|
||||||
|
CastSettings.Resolution mode = settings.getResolution();
|
||||||
|
int capLong = 0;
|
||||||
|
int boxW = 0;
|
||||||
|
int boxH = 0;
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case HD_720P:
|
||||||
|
capLong = 720;
|
||||||
|
break;
|
||||||
|
case FIXED_1080P:
|
||||||
|
boxW = 1920;
|
||||||
|
boxH = 1080;
|
||||||
|
break;
|
||||||
|
case FIXED_720P:
|
||||||
|
boxW = 1280;
|
||||||
|
boxH = 720;
|
||||||
|
break;
|
||||||
|
case ADAPTIVE:
|
||||||
|
capLong = 720;
|
||||||
|
if (peer != null) {
|
||||||
|
if (peer.recvBitrateKbps > 0 && peer.recvBitrateKbps < 800) {
|
||||||
|
capLong = 480;
|
||||||
|
} else if (peer.recvBitrateKbps > 0 && peer.recvBitrateKbps < 1500) {
|
||||||
|
capLong = 540;
|
||||||
|
}
|
||||||
|
if (peer.rttMs > 500) {
|
||||||
|
capLong = Math.min(capLong, 540);
|
||||||
|
}
|
||||||
|
if (peer.rttMs > 800) {
|
||||||
|
capLong = Math.min(capLong, 480);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return new CaptureLimits(settings.getMaxWidth(), settings.getMaxHeight());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boxW > 0 && boxH > 0) {
|
||||||
|
return new CaptureLimits(boxW, boxH);
|
||||||
|
}
|
||||||
|
return new CaptureLimits(0, capLong);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EffectiveParams resolve(CastSettings settings, NetworkStatsSnapshot peerStats) {
|
||||||
|
CastSettings.Quality quality = settings.getQuality();
|
||||||
|
int bitrate = quality.videoBitrate;
|
||||||
|
int fps = quality.videoFps;
|
||||||
|
int gop = CastConfig.VIDEO_I_FRAME_INTERVAL;
|
||||||
|
boolean lowLatency = true;
|
||||||
|
boolean skipTiny = true;
|
||||||
|
|
||||||
|
switch (settings.getAdjustmentPreset()) {
|
||||||
|
case NONE:
|
||||||
|
lowLatency = false;
|
||||||
|
skipTiny = false;
|
||||||
|
break;
|
||||||
|
case QUALITY:
|
||||||
|
bitrate = (int) (bitrate * 1.15f);
|
||||||
|
fps = Math.min(30, fps + 2);
|
||||||
|
gop = 2;
|
||||||
|
break;
|
||||||
|
case PERFORMANCE:
|
||||||
|
bitrate = (int) (bitrate * 0.85f);
|
||||||
|
fps = Math.max(24, fps);
|
||||||
|
break;
|
||||||
|
case SPEED:
|
||||||
|
case AUTO:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (settings.getBitrateMode()) {
|
||||||
|
case NONE:
|
||||||
|
bitrate = Integer.MAX_VALUE / 4;
|
||||||
|
break;
|
||||||
|
case QUALITY:
|
||||||
|
bitrate = (int) (bitrate * 1.25f);
|
||||||
|
break;
|
||||||
|
case SPEED:
|
||||||
|
bitrate = (int) (bitrate * 0.65f);
|
||||||
|
fps = Math.min(fps, 24);
|
||||||
|
break;
|
||||||
|
case ROBUST_LESS:
|
||||||
|
if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) {
|
||||||
|
bitrate = (int) (bitrate * 0.55f);
|
||||||
|
gop = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ESTIMATED:
|
||||||
|
case BALANCED:
|
||||||
|
case AUTO:
|
||||||
|
default:
|
||||||
|
bitrate = applyBandwidthEstimate(peerStats, bitrate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (settings.getNetworkAdoption()) {
|
||||||
|
case QUALITY:
|
||||||
|
bitrate = (int) (bitrate * 1.1f);
|
||||||
|
break;
|
||||||
|
case SPEED:
|
||||||
|
bitrate = (int) (bitrate * 0.7f);
|
||||||
|
fps = Math.min(fps, 24);
|
||||||
|
break;
|
||||||
|
case ESTIMATED:
|
||||||
|
bitrate = applyBandwidthEstimate(peerStats, bitrate);
|
||||||
|
break;
|
||||||
|
case AUTO:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bitrate = Math.max(600_000, Math.min(16_000_000, bitrate));
|
||||||
|
fps = Math.max(15, Math.min(30, fps));
|
||||||
|
return new EffectiveParams(bitrate, fps, gop, lowLatency, skipTiny);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int applyBandwidthEstimate(NetworkStatsSnapshot peer, int bitrate) {
|
||||||
|
if (peer == null) {
|
||||||
|
return bitrate;
|
||||||
|
}
|
||||||
|
if (peer.rttMs > 400) {
|
||||||
|
float factor = Math.max(0.35f, 1f - (peer.rttMs - 400) / 2000f);
|
||||||
|
bitrate = (int) (bitrate * factor);
|
||||||
|
}
|
||||||
|
if (peer.recvBitrateKbps > 0) {
|
||||||
|
int target = peer.recvBitrateKbps * 900;
|
||||||
|
if (target < bitrate) {
|
||||||
|
bitrate = Math.max(600_000, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (peer.sendBitrateKbps > 0 && peer.sendBitrateKbps * 1000 < bitrate) {
|
||||||
|
bitrate = Math.max(600_000, peer.sendBitrateKbps * 950);
|
||||||
|
}
|
||||||
|
return bitrate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import android.widget.Spinner;
|
|||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.sender.CodecCatalog;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** Binds cast option spinners/checkbox to {@link CastSettings}. */
|
/** Binds cast option spinners/checkbox to {@link CastSettings}. */
|
||||||
public final class SettingsUi {
|
public final class SettingsUi {
|
||||||
@@ -18,60 +21,172 @@ public final class SettingsUi {
|
|||||||
PlatformCastSupport.applyDefaultAudioSetting(settings);
|
PlatformCastSupport.applyDefaultAudioSetting(settings);
|
||||||
|
|
||||||
Spinner transport = activity.findViewById(R.id.spinner_transport);
|
Spinner transport = activity.findViewById(R.id.spinner_transport);
|
||||||
|
Spinner videoCodec = activity.findViewById(R.id.spinner_video_codec);
|
||||||
|
Spinner adjustment = activity.findViewById(R.id.spinner_adjustment);
|
||||||
|
Spinner bitrateMode = activity.findViewById(R.id.spinner_bitrate_mode);
|
||||||
Spinner quality = activity.findViewById(R.id.spinner_quality);
|
Spinner quality = activity.findViewById(R.id.spinner_quality);
|
||||||
|
Spinner networkAdoption = activity.findViewById(R.id.spinner_network_adoption);
|
||||||
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
||||||
CheckBox audio = activity.findViewById(R.id.checkbox_audio);
|
CheckBox audio = activity.findViewById(R.id.checkbox_audio);
|
||||||
|
|
||||||
String[] transports = {
|
bindSpinner(activity, transport, new String[]{
|
||||||
activity.getString(R.string.transport_tcp),
|
activity.getString(R.string.transport_tcp),
|
||||||
activity.getString(R.string.transport_udp)
|
activity.getString(R.string.transport_udp)
|
||||||
};
|
}, position -> settings.setTransport(
|
||||||
transport.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, transports));
|
position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP));
|
||||||
transport.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
|
||||||
@Override
|
List<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
|
||||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
List<String> codecLabels = new ArrayList<>();
|
||||||
settings.setTransport(position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP);
|
for (CastSettings.VideoCodec c : codecs) {
|
||||||
|
codecLabels.add(labelVideoCodec(activity, c));
|
||||||
|
}
|
||||||
|
bindSpinner(activity, videoCodec, codecLabels.toArray(new String[0]), position -> {
|
||||||
|
if (position >= 0 && position < codecs.size()) {
|
||||||
|
settings.setVideoCodec(codecs.get(position));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNothingSelected(AdapterView<?> parent) {}
|
|
||||||
});
|
});
|
||||||
|
selectCodecSpinner(videoCodec, codecs, settings.getVideoCodec());
|
||||||
|
|
||||||
String[] qualities = {
|
bindEnumSpinner(activity, adjustment, CastSettings.AdjustmentPreset.values(),
|
||||||
|
SettingsUi::labelAdjustment, settings.getAdjustmentPreset().ordinal(),
|
||||||
|
p -> settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.values()[p]));
|
||||||
|
|
||||||
|
bindEnumSpinner(activity, bitrateMode, CastSettings.BitrateMode.values(),
|
||||||
|
SettingsUi::labelBitrateMode, settings.getBitrateMode().ordinal(),
|
||||||
|
p -> settings.setBitrateMode(CastSettings.BitrateMode.values()[p]));
|
||||||
|
|
||||||
|
bindSpinner(activity, quality, new String[]{
|
||||||
activity.getString(R.string.quality_low),
|
activity.getString(R.string.quality_low),
|
||||||
activity.getString(R.string.quality_medium),
|
activity.getString(R.string.quality_medium),
|
||||||
activity.getString(R.string.quality_high)
|
activity.getString(R.string.quality_high)
|
||||||
};
|
}, position -> settings.setQuality(CastSettings.Quality.values()[position]));
|
||||||
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities));
|
quality.setSelection(CastSettings.Quality.MEDIUM.ordinal());
|
||||||
quality.setSelection(1);
|
|
||||||
resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal());
|
|
||||||
quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
|
||||||
@Override
|
|
||||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
|
||||||
settings.setQuality(CastSettings.Quality.values()[position]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
bindEnumSpinner(activity, networkAdoption, CastSettings.NetworkAdoption.values(),
|
||||||
public void onNothingSelected(AdapterView<?> parent) {}
|
SettingsUi::labelNetworkAdoption, settings.getNetworkAdoption().ordinal(),
|
||||||
});
|
p -> settings.setNetworkAdoption(CastSettings.NetworkAdoption.values()[p]));
|
||||||
|
|
||||||
String[] resolutions = {
|
String[] resolutions = {
|
||||||
activity.getString(R.string.resolution_full),
|
activity.getString(R.string.resolution_full),
|
||||||
activity.getString(R.string.resolution_75),
|
activity.getString(R.string.resolution_75),
|
||||||
activity.getString(R.string.resolution_half),
|
activity.getString(R.string.resolution_half),
|
||||||
activity.getString(R.string.resolution_720p)
|
activity.getString(R.string.resolution_720p),
|
||||||
|
activity.getString(R.string.resolution_fixed_1080p),
|
||||||
|
activity.getString(R.string.resolution_fixed_720p),
|
||||||
|
activity.getString(R.string.resolution_adaptive)
|
||||||
};
|
};
|
||||||
resolution.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, resolutions));
|
bindSpinner(activity, resolution, resolutions,
|
||||||
resolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
position -> settings.setResolution(CastSettings.Resolution.values()[position]));
|
||||||
|
resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal());
|
||||||
|
|
||||||
|
PlatformCastSupport.bindAudioCheckbox(activity, audio, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface IntConsumer {
|
||||||
|
void accept(int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface LabelProvider<T> {
|
||||||
|
String label(AppCompatActivity activity, T value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T extends Enum<T>> void bindEnumSpinner(AppCompatActivity activity, Spinner spinner,
|
||||||
|
T[] values, LabelProvider<T> labels, int selection, IntConsumer onSelect) {
|
||||||
|
String[] items = new String[values.length];
|
||||||
|
for (int i = 0; i < values.length; i++) {
|
||||||
|
items[i] = labels.label(activity, values[i]);
|
||||||
|
}
|
||||||
|
bindSpinner(activity, spinner, items, onSelect);
|
||||||
|
if (selection >= 0 && selection < values.length) {
|
||||||
|
spinner.setSelection(selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void bindSpinner(AppCompatActivity activity, Spinner spinner, String[] items,
|
||||||
|
IntConsumer onSelect) {
|
||||||
|
spinner.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, items));
|
||||||
|
spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||||
settings.setResolution(CastSettings.Resolution.values()[position]);
|
onSelect.accept(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onNothingSelected(AdapterView<?> parent) {}
|
public void onNothingSelected(AdapterView<?> parent) {}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
PlatformCastSupport.bindAudioCheckbox(activity, audio, settings);
|
private static void selectCodecSpinner(Spinner spinner, List<CastSettings.VideoCodec> codecs,
|
||||||
|
CastSettings.VideoCodec selected) {
|
||||||
|
for (int i = 0; i < codecs.size(); i++) {
|
||||||
|
if (codecs.get(i) == selected) {
|
||||||
|
spinner.setSelection(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String labelVideoCodec(AppCompatActivity activity, CastSettings.VideoCodec codec) {
|
||||||
|
switch (codec) {
|
||||||
|
case H264:
|
||||||
|
return activity.getString(R.string.codec_h264);
|
||||||
|
case H265:
|
||||||
|
return activity.getString(R.string.codec_h265);
|
||||||
|
case PASSTHROUGH:
|
||||||
|
return activity.getString(R.string.codec_passthrough);
|
||||||
|
case AUTO:
|
||||||
|
default:
|
||||||
|
return activity.getString(R.string.option_auto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String labelAdjustment(AppCompatActivity activity, CastSettings.AdjustmentPreset p) {
|
||||||
|
switch (p) {
|
||||||
|
case NONE:
|
||||||
|
return activity.getString(R.string.option_none);
|
||||||
|
case QUALITY:
|
||||||
|
return activity.getString(R.string.option_quality);
|
||||||
|
case SPEED:
|
||||||
|
return activity.getString(R.string.option_speed);
|
||||||
|
case PERFORMANCE:
|
||||||
|
return activity.getString(R.string.option_performance);
|
||||||
|
case AUTO:
|
||||||
|
default:
|
||||||
|
return activity.getString(R.string.option_auto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String labelBitrateMode(AppCompatActivity activity, CastSettings.BitrateMode m) {
|
||||||
|
switch (m) {
|
||||||
|
case NONE:
|
||||||
|
return activity.getString(R.string.option_none);
|
||||||
|
case QUALITY:
|
||||||
|
return activity.getString(R.string.option_quality);
|
||||||
|
case SPEED:
|
||||||
|
return activity.getString(R.string.option_speed);
|
||||||
|
case BALANCED:
|
||||||
|
return activity.getString(R.string.option_balanced);
|
||||||
|
case ROBUST_LESS:
|
||||||
|
return activity.getString(R.string.option_robust_less);
|
||||||
|
case ESTIMATED:
|
||||||
|
return activity.getString(R.string.option_estimated);
|
||||||
|
case AUTO:
|
||||||
|
default:
|
||||||
|
return activity.getString(R.string.option_auto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String labelNetworkAdoption(AppCompatActivity activity, CastSettings.NetworkAdoption n) {
|
||||||
|
switch (n) {
|
||||||
|
case QUALITY:
|
||||||
|
return activity.getString(R.string.option_quality);
|
||||||
|
case SPEED:
|
||||||
|
return activity.getString(R.string.option_speed);
|
||||||
|
case ESTIMATED:
|
||||||
|
return activity.getString(R.string.option_estimated);
|
||||||
|
case AUTO:
|
||||||
|
default:
|
||||||
|
return activity.getString(R.string.option_auto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ public class DiscoveryManager {
|
|||||||
private final ConcurrentHashMap<String, DiscoveredDevice> devices = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, DiscoveredDevice> devices = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private Listener listener;
|
private Listener listener;
|
||||||
|
private volatile DatagramSocket socket;
|
||||||
|
private final Object lockGuard = new Object();
|
||||||
private WifiManager.MulticastLock multicastLock;
|
private WifiManager.MulticastLock multicastLock;
|
||||||
private DatagramSocket socket;
|
private int multicastHolds;
|
||||||
|
|
||||||
public DiscoveryManager(Context context) {
|
public DiscoveryManager(Context context) {
|
||||||
this.appContext = context.getApplicationContext();
|
this.appContext = context.getApplicationContext();
|
||||||
@@ -71,16 +73,23 @@ public class DiscoveryManager {
|
|||||||
executor.execute(this::browseLoop);
|
executor.execute(this::browseLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops discovery I/O. The background loop releases the multicast lock in its {@code finally}
|
||||||
|
* block — do not release here (double-release crashed the sender when cast started).
|
||||||
|
*/
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running.set(false);
|
running.set(false);
|
||||||
if (socket != null) {
|
DatagramSocket toClose = socket;
|
||||||
socket.close();
|
socket = null;
|
||||||
|
if (toClose != null) {
|
||||||
|
try {
|
||||||
|
toClose.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
releaseMulticastLock();
|
|
||||||
devices.clear();
|
devices.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears cached devices and notifies listener with an empty list. */
|
|
||||||
public void clearDevices() {
|
public void clearDevices() {
|
||||||
devices.clear();
|
devices.clear();
|
||||||
notifyListener();
|
notifyListener();
|
||||||
@@ -115,7 +124,9 @@ public class DiscoveryManager {
|
|||||||
Log.e(TAG, "Browse failed", e);
|
Log.e(TAG, "Browse failed", e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
socket = null;
|
||||||
releaseMulticastLock();
|
releaseMulticastLock();
|
||||||
|
running.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,13 +153,16 @@ public class DiscoveryManager {
|
|||||||
Log.e(TAG, "Announce failed", e);
|
Log.e(TAG, "Announce failed", e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
socket = null;
|
||||||
releaseMulticastLock();
|
releaseMulticastLock();
|
||||||
|
running.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handlePacket(DatagramPacket packet) {
|
private void handlePacket(DatagramPacket packet) {
|
||||||
try {
|
try {
|
||||||
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
|
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
JSONObject json = new JSONObject(text);
|
JSONObject json = new JSONObject(text);
|
||||||
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
|
if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
|
||||||
return;
|
return;
|
||||||
@@ -183,18 +197,37 @@ public class DiscoveryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void acquireMulticastLock() {
|
private void acquireMulticastLock() {
|
||||||
|
synchronized (lockGuard) {
|
||||||
|
if (multicastLock == null) {
|
||||||
WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE);
|
WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE);
|
||||||
if (wifi != null) {
|
if (wifi == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
multicastLock = wifi.createMulticastLock("androidcast-discovery");
|
multicastLock = wifi.createMulticastLock("androidcast-discovery");
|
||||||
multicastLock.setReferenceCounted(true);
|
multicastLock.setReferenceCounted(false);
|
||||||
|
}
|
||||||
|
if (multicastHolds == 0) {
|
||||||
multicastLock.acquire();
|
multicastLock.acquire();
|
||||||
}
|
}
|
||||||
|
multicastHolds++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void releaseMulticastLock() {
|
private void releaseMulticastLock() {
|
||||||
if (multicastLock != null && multicastLock.isHeld()) {
|
synchronized (lockGuard) {
|
||||||
|
if (multicastLock == null || multicastHolds <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
multicastHolds--;
|
||||||
|
if (multicastHolds == 0) {
|
||||||
|
try {
|
||||||
|
if (multicastLock.isHeld()) {
|
||||||
multicastLock.release();
|
multicastLock.release();
|
||||||
}
|
}
|
||||||
multicastLock = null;
|
} catch (RuntimeException e) {
|
||||||
|
Log.w(TAG, "MulticastLock release: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import android.media.MediaFormat;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Picks a video MIME type both sides support, honoring user preference when possible. */
|
||||||
|
public final class CodecNegotiator {
|
||||||
|
private static final String[] PREFERENCE_ORDER = {
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_VP9,
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_VP8,
|
||||||
|
};
|
||||||
|
|
||||||
|
private CodecNegotiator() {}
|
||||||
|
|
||||||
|
public static String negotiate(List<String> senderEncoders, List<String> receiverDecoders,
|
||||||
|
CastSettings.VideoCodec preference) {
|
||||||
|
Set<String> enc = new HashSet<>(senderEncoders);
|
||||||
|
Set<String> dec = new HashSet<>(receiverDecoders);
|
||||||
|
if (preference == CastSettings.VideoCodec.H264) {
|
||||||
|
return pick(MediaFormat.MIMETYPE_VIDEO_AVC, enc, dec);
|
||||||
|
}
|
||||||
|
if (preference == CastSettings.VideoCodec.H265) {
|
||||||
|
return pick(MediaFormat.MIMETYPE_VIDEO_HEVC, enc, dec);
|
||||||
|
}
|
||||||
|
for (String mime : PREFERENCE_ORDER) {
|
||||||
|
if (enc.contains(mime) && dec.contains(mime)) {
|
||||||
|
return mime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dec.contains(MediaFormat.MIMETYPE_VIDEO_AVC) && enc.contains(MediaFormat.MIMETYPE_VIDEO_AVC)) {
|
||||||
|
return MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("No common video codec (sender=" + enc + " receiver=" + dec + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pick(String mime, Set<String> enc, Set<String> dec) {
|
||||||
|
if (enc.contains(mime) && dec.contains(mime)) {
|
||||||
|
return mime;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Preferred codec not available on both sides: " + mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String displayName(String mime) {
|
||||||
|
if (mime == null) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) {
|
||||||
|
return "H.264";
|
||||||
|
}
|
||||||
|
if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) {
|
||||||
|
return "H.265";
|
||||||
|
}
|
||||||
|
if (MediaFormat.MIMETYPE_VIDEO_VP9.equals(mime)) {
|
||||||
|
return "VP9";
|
||||||
|
}
|
||||||
|
if (MediaFormat.MIMETYPE_VIDEO_VP8.equals(mime)) {
|
||||||
|
return "VP8";
|
||||||
|
}
|
||||||
|
int slash = mime.lastIndexOf('/');
|
||||||
|
return slash >= 0 ? mime.substring(slash + 1).toUpperCase(Locale.US) : mime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isH264(String mime) {
|
||||||
|
return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import com.foxx.androidcast.CastSettings;
|
|||||||
import com.foxx.androidcast.network.control.NetworkControlAdvice;
|
import com.foxx.androidcast.network.control.NetworkControlAdvice;
|
||||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
|
||||||
|
import android.media.MediaFormat;
|
||||||
|
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -27,6 +29,8 @@ public final class CastProtocol {
|
|||||||
public static final byte MSG_NETWORK_STATS = 11;
|
public static final byte MSG_NETWORK_STATS = 11;
|
||||||
public static final byte MSG_NETWORK_CONTROL = 12;
|
public static final byte MSG_NETWORK_CONTROL = 12;
|
||||||
public static final byte MSG_AUDIO_CONSENT = 13;
|
public static final byte MSG_AUDIO_CONSENT = 13;
|
||||||
|
public static final byte MSG_CODEC_CAPS = 14;
|
||||||
|
public static final byte MSG_CODEC_SELECTED = 15;
|
||||||
|
|
||||||
private CastProtocol() {}
|
private CastProtocol() {}
|
||||||
|
|
||||||
@@ -64,10 +68,15 @@ public final class CastProtocol {
|
|||||||
public static byte[] castSettingsPayload(CastSettings settings) throws IOException {
|
public static byte[] castSettingsPayload(CastSettings settings) throws IOException {
|
||||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||||
DataOutputStream dos = new DataOutputStream(bos);
|
DataOutputStream dos = new DataOutputStream(bos);
|
||||||
|
dos.writeInt(CastSettings.SETTINGS_VERSION);
|
||||||
dos.writeUTF(settings.getTransport());
|
dos.writeUTF(settings.getTransport());
|
||||||
dos.writeInt(settings.getQuality().ordinal());
|
dos.writeInt(settings.getQuality().ordinal());
|
||||||
dos.writeInt(settings.getResolution().ordinal());
|
dos.writeInt(settings.getResolution().ordinal());
|
||||||
dos.writeByte(settings.isAudioEnabled() ? 1 : 0);
|
dos.writeByte(settings.isAudioEnabled() ? 1 : 0);
|
||||||
|
dos.writeInt(settings.getVideoCodec().ordinal());
|
||||||
|
dos.writeInt(settings.getAdjustmentPreset().ordinal());
|
||||||
|
dos.writeInt(settings.getBitrateMode().ordinal());
|
||||||
|
dos.writeInt(settings.getNetworkAdoption().ordinal());
|
||||||
dos.flush();
|
dos.flush();
|
||||||
return bos.toByteArray();
|
return bos.toByteArray();
|
||||||
}
|
}
|
||||||
@@ -75,15 +84,84 @@ public final class CastProtocol {
|
|||||||
public static CastSettings parseCastSettings(byte[] payload) throws IOException {
|
public static CastSettings parseCastSettings(byte[] payload) throws IOException {
|
||||||
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||||
CastSettings settings = new CastSettings();
|
CastSettings settings = new CastSettings();
|
||||||
|
dis.mark(64);
|
||||||
|
int first = dis.readInt();
|
||||||
|
if (first == CastSettings.SETTINGS_VERSION) {
|
||||||
settings.setTransport(dis.readUTF());
|
settings.setTransport(dis.readUTF());
|
||||||
int q = dis.readInt();
|
settings.setQuality(readEnum(CastSettings.Quality.values(), dis.readInt(), CastSettings.Quality.MEDIUM));
|
||||||
int r = dis.readInt();
|
settings.setResolution(readEnum(CastSettings.Resolution.values(), dis.readInt(),
|
||||||
settings.setQuality(CastSettings.Quality.values()[q]);
|
CastSettings.Resolution.HD_720P));
|
||||||
settings.setResolution(CastSettings.Resolution.values()[r]);
|
|
||||||
settings.setAudioEnabled(dis.readByte() != 0);
|
settings.setAudioEnabled(dis.readByte() != 0);
|
||||||
|
if (dis.available() > 0) {
|
||||||
|
settings.setVideoCodec(readEnum(CastSettings.VideoCodec.values(), dis.readInt(),
|
||||||
|
CastSettings.VideoCodec.AUTO));
|
||||||
|
}
|
||||||
|
if (dis.available() > 0) {
|
||||||
|
settings.setAdjustmentPreset(readEnum(CastSettings.AdjustmentPreset.values(), dis.readInt(),
|
||||||
|
CastSettings.AdjustmentPreset.AUTO));
|
||||||
|
}
|
||||||
|
if (dis.available() > 0) {
|
||||||
|
settings.setBitrateMode(readEnum(CastSettings.BitrateMode.values(), dis.readInt(),
|
||||||
|
CastSettings.BitrateMode.AUTO));
|
||||||
|
}
|
||||||
|
if (dis.available() > 0) {
|
||||||
|
settings.setNetworkAdoption(readEnum(CastSettings.NetworkAdoption.values(), dis.readInt(),
|
||||||
|
CastSettings.NetworkAdoption.AUTO));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dis.reset();
|
||||||
|
settings.setTransport(dis.readUTF());
|
||||||
|
settings.setQuality(readEnum(CastSettings.Quality.values(), dis.readInt(), CastSettings.Quality.MEDIUM));
|
||||||
|
settings.setResolution(readEnum(CastSettings.Resolution.values(), dis.readInt(),
|
||||||
|
CastSettings.Resolution.HD_720P));
|
||||||
|
settings.setAudioEnabled(dis.readByte() != 0);
|
||||||
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static <T extends Enum<T>> T readEnum(T[] values, int ordinal, T fallback) {
|
||||||
|
if (ordinal >= 0 && ordinal < values.length) {
|
||||||
|
return values[ordinal];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] codecCapsPayload(java.util.List<String> mimeTypes) throws IOException {
|
||||||
|
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||||
|
DataOutputStream dos = new DataOutputStream(bos);
|
||||||
|
dos.writeInt(mimeTypes != null ? mimeTypes.size() : 0);
|
||||||
|
if (mimeTypes != null) {
|
||||||
|
for (String mime : mimeTypes) {
|
||||||
|
dos.writeUTF(mime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dos.flush();
|
||||||
|
return bos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static java.util.List<String> parseCodecCaps(byte[] payload) throws IOException {
|
||||||
|
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||||
|
int n = dis.readInt();
|
||||||
|
java.util.List<String> list = new java.util.ArrayList<>(Math.max(0, n));
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
list.add(dis.readUTF());
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] codecSelectedPayload(String mime) throws IOException {
|
||||||
|
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||||
|
DataOutputStream dos = new DataOutputStream(bos);
|
||||||
|
dos.writeUTF(mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC);
|
||||||
|
dos.flush();
|
||||||
|
return bos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String parseCodecSelected(byte[] payload) throws IOException {
|
||||||
|
DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
|
||||||
|
return dis.readUTF();
|
||||||
|
}
|
||||||
|
|
||||||
public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException {
|
public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException {
|
||||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||||
DataOutputStream dos = new DataOutputStream(bos);
|
DataOutputStream dos = new DataOutputStream(bos);
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
package com.foxx.androidcast.network;
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
import com.foxx.androidcast.media.CodecNegotiator;
|
||||||
|
import com.foxx.androidcast.sender.CodecCatalog;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/** Auth handshake and message I/O over a {@link CastTransport}. */
|
/** Auth handshake and message I/O over a {@link CastTransport}. */
|
||||||
public class CastSession {
|
public class CastSession {
|
||||||
public static final class HandshakeResult {
|
public static final class HandshakeResult {
|
||||||
public final String senderName;
|
public final String senderName;
|
||||||
public final CastSettings settings;
|
public final CastSettings settings;
|
||||||
|
public final String negotiatedVideoMime;
|
||||||
|
|
||||||
public HandshakeResult(String senderName, CastSettings settings) {
|
public HandshakeResult(String senderName, CastSettings settings, String negotiatedVideoMime) {
|
||||||
this.senderName = senderName;
|
this.senderName = senderName;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
|
this.negotiatedVideoMime = negotiatedVideoMime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +47,7 @@ public class CastSession {
|
|||||||
transport.connect(host, port);
|
transport.connect(host, port);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException {
|
public HandshakeResult clientHandshake(String deviceName, String pin, CastSettings settings) throws IOException {
|
||||||
transport.send(CastProtocol.MSG_HELLO, CastProtocol.helloPayload(deviceName));
|
transport.send(CastProtocol.MSG_HELLO, CastProtocol.helloPayload(deviceName));
|
||||||
transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin));
|
transport.send(CastProtocol.MSG_AUTH, CastProtocol.authPayload(pin));
|
||||||
CastProtocol.Message reply = transport.receive(10_000);
|
CastProtocol.Message reply = transport.receive(10_000);
|
||||||
@@ -50,11 +55,32 @@ public class CastSession {
|
|||||||
throw new IOException("Authentication failed");
|
throw new IOException("Authentication failed");
|
||||||
}
|
}
|
||||||
transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings));
|
transport.send(CastProtocol.MSG_CAST_SETTINGS, CastProtocol.castSettingsPayload(settings));
|
||||||
|
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(CodecCatalog.localEncoderMimes()));
|
||||||
|
|
||||||
|
String negotiated = null;
|
||||||
|
long deadline = System.currentTimeMillis() + 15_000;
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
CastProtocol.Message msg = transport.receive(2_000);
|
||||||
|
if (msg == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg.type == CastProtocol.MSG_CODEC_SELECTED) {
|
||||||
|
negotiated = CastProtocol.parseCodecSelected(msg.payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (negotiated == null) {
|
||||||
|
throw new IOException("Codec negotiation timeout");
|
||||||
|
}
|
||||||
|
settings.setNegotiatedVideoMime(negotiated);
|
||||||
|
return new HandshakeResult(deviceName, settings, negotiated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
|
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
|
||||||
String senderName = "Sender";
|
String senderName = "Sender";
|
||||||
boolean authed = false;
|
boolean authed = false;
|
||||||
|
CastSettings remote = null;
|
||||||
|
List<String> senderCaps = null;
|
||||||
long deadline = System.currentTimeMillis() + 30_000;
|
long deadline = System.currentTimeMillis() + 30_000;
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
CastProtocol.Message msg = transport.receive(2_000);
|
CastProtocol.Message msg = transport.receive(2_000);
|
||||||
@@ -75,18 +101,26 @@ public class CastSession {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case CastProtocol.MSG_CAST_SETTINGS:
|
case CastProtocol.MSG_CAST_SETTINGS:
|
||||||
if (!authed) {
|
remote = CastProtocol.parseCastSettings(msg.payload);
|
||||||
break;
|
|
||||||
}
|
|
||||||
CastSettings remote = CastProtocol.parseCastSettings(msg.payload);
|
|
||||||
if (!CastProtocol.transportMatches(localSettings, remote)) {
|
if (!CastProtocol.transportMatches(localSettings, remote)) {
|
||||||
throw new IOException("Transport mismatch: receiver="
|
throw new IOException("Transport mismatch: receiver="
|
||||||
+ localSettings.getTransport() + " sender=" + remote.getTransport());
|
+ localSettings.getTransport() + " sender=" + remote.getTransport());
|
||||||
}
|
}
|
||||||
return new HandshakeResult(senderName, remote);
|
break;
|
||||||
|
case CastProtocol.MSG_CODEC_CAPS:
|
||||||
|
senderCaps = CastProtocol.parseCodecCaps(msg.payload);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (authed && remote != null && senderCaps != null) {
|
||||||
|
List<String> receiverCaps = CodecCatalog.localDecoderMimes();
|
||||||
|
transport.send(CastProtocol.MSG_CODEC_CAPS, CastProtocol.codecCapsPayload(receiverCaps));
|
||||||
|
String mime = CodecNegotiator.negotiate(senderCaps, receiverCaps, remote.getVideoCodec());
|
||||||
|
transport.send(CastProtocol.MSG_CODEC_SELECTED, CastProtocol.codecSelectedPayload(mime));
|
||||||
|
remote.setNegotiatedVideoMime(mime);
|
||||||
|
return new HandshakeResult(senderName, remote, mime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new IOException("Handshake timeout");
|
throw new IOException("Handshake timeout");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
|||||||
return outbound;
|
return outbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public NetworkStatsSnapshot getLastPeerStats() {
|
||||||
|
return lastPeer != null ? lastPeer.copy() : null;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPeerStats(NetworkStatsSnapshot stats) {
|
public void onPeerStats(NetworkStatsSnapshot stats) {
|
||||||
lastPeer = stats != null ? stats.copy() : null;
|
lastPeer = stats != null ? stats.copy() : null;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public class ReceiverCastService extends Service {
|
|||||||
private PowerManager.WakeLock wakeLock;
|
private PowerManager.WakeLock wakeLock;
|
||||||
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
|
private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
|
||||||
private final StreamMetrics streamMetrics = new StreamMetrics();
|
private final StreamMetrics streamMetrics = new StreamMetrics();
|
||||||
|
private String negotiatedVideoMime;
|
||||||
private boolean streamIdle;
|
private boolean streamIdle;
|
||||||
private long lastVideoFrameMs;
|
private long lastVideoFrameMs;
|
||||||
|
|
||||||
@@ -189,8 +190,8 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAuthenticated(String senderName, CastSettings remoteSettings) {
|
public void onAuthenticated(String senderName, CastSettings remoteSettings, String videoMime) {
|
||||||
mainHandler.post(() -> onSenderConnected(senderName));
|
mainHandler.post(() -> onSenderConnected(senderName, remoteSettings, videoMime));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -271,16 +272,34 @@ public class ReceiverCastService extends Service {
|
|||||||
Log.i(TAG, "Listening PIN=" + pin + " audio=" + allowIncomingAudio);
|
Log.i(TAG, "Listening PIN=" + pin + " audio=" + allowIncomingAudio);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onSenderConnected(String senderName) {
|
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
|
||||||
phase = Phase.CONNECTED;
|
phase = Phase.CONNECTED;
|
||||||
|
negotiatedVideoMime = videoMime;
|
||||||
audioAccepted = null;
|
audioAccepted = null;
|
||||||
streamIdle = false;
|
streamIdle = false;
|
||||||
streamMetrics.reset();
|
streamMetrics.reset();
|
||||||
lastVideoFrameMs = 0;
|
lastVideoFrameMs = 0;
|
||||||
updateStatus(getString(R.string.casting_from, senderName));
|
String codecLabel = videoMime != null
|
||||||
|
? com.foxx.androidcast.media.CodecNegotiator.displayName(videoMime) : "?";
|
||||||
|
updateStatus(getString(R.string.casting_from_codec, senderName, codecLabel));
|
||||||
broadcastAuthenticated(senderName);
|
broadcastAuthenticated(senderName);
|
||||||
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
openPlaybackAwaiting(R.string.playback_awaiting_stream);
|
||||||
Log.i(TAG, "Sender connected: " + senderName);
|
if (remoteSettings != null && remoteSettings.isAudioRequested() && allowIncomingAudio) {
|
||||||
|
audioAccepted = true;
|
||||||
|
sendAudioConsentAsync(true);
|
||||||
|
Log.i(TAG, "Audio accepted for sender (pre-config)");
|
||||||
|
} else if (remoteSettings != null && remoteSettings.isAudioRequested()) {
|
||||||
|
updateStatus(getString(R.string.audio_skipped));
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Sender connected: " + senderName + " codec=" + videoMime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendAudioConsentAsync(final boolean accept) {
|
||||||
|
new Thread(() -> {
|
||||||
|
if (session != null) {
|
||||||
|
session.sendAudioConsent(accept);
|
||||||
|
}
|
||||||
|
}, "AudioConsent").start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onSenderDisconnected() {
|
private void onSenderDisconnected() {
|
||||||
@@ -502,7 +521,9 @@ public class ReceiverCastService extends Service {
|
|||||||
videoDecoder.release();
|
videoDecoder.release();
|
||||||
}
|
}
|
||||||
videoDecoder = new VideoDecoder();
|
videoDecoder = new VideoDecoder();
|
||||||
videoDecoder.configure(pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface,
|
String mime = negotiatedVideoMime != null ? negotiatedVideoMime
|
||||||
|
: android.media.MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||||
|
videoDecoder.configure(mime, pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface,
|
||||||
new VideoDecoder.Listener() {
|
new VideoDecoder.Listener() {
|
||||||
@Override
|
@Override
|
||||||
public void onFirstFrameRendered() {
|
public void onFirstFrameRendered() {
|
||||||
@@ -544,6 +565,7 @@ public class ReceiverCastService extends Service {
|
|||||||
private String buildDiagnosticsText() {
|
private String buildDiagnosticsText() {
|
||||||
return streamMetrics.format(
|
return streamMetrics.format(
|
||||||
settings.getTransport(),
|
settings.getTransport(),
|
||||||
|
negotiatedVideoMime,
|
||||||
pendingWidth,
|
pendingWidth,
|
||||||
pendingHeight,
|
pendingHeight,
|
||||||
decoderWidth,
|
decoderWidth,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class ReceiverSession {
|
|||||||
public interface Listener {
|
public interface Listener {
|
||||||
void onStatus(String status);
|
void onStatus(String status);
|
||||||
|
|
||||||
void onAuthenticated(String senderName, CastSettings remoteSettings);
|
void onAuthenticated(String senderName, CastSettings remoteSettings, String negotiatedVideoMime);
|
||||||
|
|
||||||
void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1);
|
void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1);
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ public class ReceiverSession {
|
|||||||
networkFeedback = new NetworkFeedbackManager(session, false);
|
networkFeedback = new NetworkFeedbackManager(session, false);
|
||||||
session.acceptClient();
|
session.acceptClient();
|
||||||
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
||||||
listener.onAuthenticated(hs.senderName, hs.settings);
|
listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
|
||||||
postStatus("Streaming from " + hs.senderName);
|
postStatus("Streaming from " + hs.senderName);
|
||||||
readStream();
|
readStream();
|
||||||
} catch (java.io.EOFException e) {
|
} catch (java.io.EOFException e) {
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ public final class StreamMetrics {
|
|||||||
return lastVideoPacketMs;
|
return lastVideoPacketMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH,
|
public synchronized String format(String transport, String videoCodec, int streamW, int streamH, int renderW,
|
||||||
boolean idle) {
|
int renderH, boolean idle) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
|
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
|
||||||
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
|
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
|
||||||
@@ -102,6 +102,10 @@ public final class StreamMetrics {
|
|||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append("Transport: ").append(transport != null ? transport.toUpperCase() : "?").append('\n');
|
sb.append("Transport: ").append(transport != null ? transport.toUpperCase() : "?").append('\n');
|
||||||
|
if (videoCodec != null && !videoCodec.isEmpty()) {
|
||||||
|
sb.append("Codec: ").append(
|
||||||
|
com.foxx.androidcast.media.CodecNegotiator.displayName(videoCodec)).append('\n');
|
||||||
|
}
|
||||||
sb.append("Stream: ").append(streamW).append('x').append(streamH);
|
sb.append("Stream: ").append(streamW).append('x').append(streamH);
|
||||||
if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
|
if (renderW > 0 && renderH > 0 && (renderW != streamW || renderH != streamH)) {
|
||||||
sb.append(" render: ").append(renderW).append('x').append(renderH);
|
sb.append(" render: ").append(renderW).append('x').append(renderH);
|
||||||
@@ -122,13 +126,23 @@ public final class StreamMetrics {
|
|||||||
sb.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
|
sb.append(" ~").append(Math.max(1, avgKb)).append(" KB/frame");
|
||||||
}
|
}
|
||||||
sb.append('\n');
|
sb.append('\n');
|
||||||
if (inFps > 0 && inFps < 10f) {
|
if (inFps > 0 && inFps < 8f) {
|
||||||
sb.append("Hint: low In FPS → sender encode or TCP backlog\n");
|
sb.append("Hint: low In FPS → sender encode stall / tiny frames / TCP backlog\n");
|
||||||
|
}
|
||||||
|
if (localKbps > 0 && localKbps < 350 && inFps < 8f && videoPackets > 30) {
|
||||||
|
sb.append("Hint: very low BW — static screen or throttled encoder\n");
|
||||||
|
}
|
||||||
|
if (peerRttMs > 450 && inFps < 15f) {
|
||||||
|
sb.append("Hint: high RTT ").append(peerRttMs).append(" ms — Wi‑Fi congestion\n");
|
||||||
}
|
}
|
||||||
if (renderFps > 0 && inFps > renderFps + 3f) {
|
if (renderFps > 0 && inFps > renderFps + 3f) {
|
||||||
sb.append("Hint: decode slower than network\n");
|
sb.append("Hint: decode slower than network\n");
|
||||||
}
|
}
|
||||||
sb.append("Audio pkts: ").append(audioPackets).append('\n');
|
sb.append("Audio pkts: ").append(audioPackets);
|
||||||
|
if (audioPackets == 0 && videoPackets > 20) {
|
||||||
|
sb.append(" (play media on sender for capture audio)");
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
sb.append("Video bytes: ").append(formatBytes(videoBytes));
|
sb.append("Video bytes: ").append(formatBytes(videoBytes));
|
||||||
sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n');
|
sb.append(" Audio bytes: ").append(formatBytes(audioBytes)).append('\n');
|
||||||
sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming");
|
sb.append("State: ").append(idle ? "idle (awaiting)" : "streaming");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.media.MediaFormat;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.media.CodecNegotiator;
|
||||||
import com.foxx.androidcast.media.H264Bitstream;
|
import com.foxx.androidcast.media.H264Bitstream;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -12,10 +13,9 @@ import java.nio.ByteBuffer;
|
|||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
|
|
||||||
/** Thread-safe H.264 decoder feeding a Surface. */
|
/** Thread-safe hardware video decoder feeding a Surface. */
|
||||||
public class VideoDecoder {
|
public class VideoDecoder {
|
||||||
private static final String TAG = "VideoDecoder";
|
private static final String TAG = "VideoDecoder";
|
||||||
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
|
||||||
|
|
||||||
public interface Listener {
|
public interface Listener {
|
||||||
void onFirstFrameRendered();
|
void onFirstFrameRendered();
|
||||||
@@ -32,26 +32,31 @@ public class VideoDecoder {
|
|||||||
private Listener listener;
|
private Listener listener;
|
||||||
private boolean firstFrameNotified;
|
private boolean firstFrameNotified;
|
||||||
|
|
||||||
public void configure(int width, int height, byte[] csd0, byte[] csd1, Surface surface, Listener listener)
|
private String mime = MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||||
throws IOException {
|
|
||||||
|
public void configure(String mime, int width, int height, byte[] csd0, byte[] csd1, Surface surface,
|
||||||
|
Listener listener) throws IOException {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
releaseLocked();
|
releaseLocked();
|
||||||
released = false;
|
released = false;
|
||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
|
this.mime = mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||||
firstFrameNotified = false;
|
firstFrameNotified = false;
|
||||||
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
|
MediaFormat format = MediaFormat.createVideoFormat(this.mime, width, height);
|
||||||
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height * 2);
|
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, width * height * 2);
|
||||||
if (csd0 != null && csd0.length > 0) {
|
if (csd0 != null && csd0.length > 0) {
|
||||||
format.setByteBuffer("csd-0", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd0)));
|
byte[] c0 = CodecNegotiator.isH264(this.mime) ? H264Bitstream.toAnnexB(csd0) : csd0;
|
||||||
|
format.setByteBuffer("csd-0", ByteBuffer.wrap(c0));
|
||||||
}
|
}
|
||||||
if (csd1 != null && csd1.length > 0) {
|
if (csd1 != null && csd1.length > 0) {
|
||||||
format.setByteBuffer("csd-1", ByteBuffer.wrap(H264Bitstream.toAnnexB(csd1)));
|
byte[] c1 = CodecNegotiator.isH264(this.mime) ? H264Bitstream.toAnnexB(csd1) : csd1;
|
||||||
|
format.setByteBuffer("csd-1", ByteBuffer.wrap(c1));
|
||||||
}
|
}
|
||||||
codec = MediaCodec.createDecoderByType(MIME);
|
codec = MediaCodec.createDecoderByType(this.mime);
|
||||||
codec.configure(format, surface, null, 0);
|
codec.configure(format, surface, null, 0);
|
||||||
codec.start();
|
codec.start();
|
||||||
configured = true;
|
configured = true;
|
||||||
Log.i(TAG, "Configured " + width + "x" + height);
|
Log.i(TAG, "Configured " + CodecNegotiator.displayName(this.mime) + " " + width + "x" + height);
|
||||||
drainPendingLocked();
|
drainPendingLocked();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +66,7 @@ public class VideoDecoder {
|
|||||||
if (released || data == null || data.length == 0) {
|
if (released || data == null || data.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
byte[] annexB = H264Bitstream.toAnnexB(data);
|
byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data;
|
||||||
if (!configured || codec == null) {
|
if (!configured || codec == null) {
|
||||||
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
|
pending.add(new PendingFrame(ptsUs, keyFrame, annexB));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ public class AudioCapture {
|
|||||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||||
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
record = new AudioRecord.Builder()
|
record = new AudioRecord.Builder()
|
||||||
@@ -57,6 +59,7 @@ public class AudioCapture {
|
|||||||
|
|
||||||
running = true;
|
running = true;
|
||||||
record.startRecording();
|
record.startRecording();
|
||||||
|
Log.i(TAG, "Playback audio capture started");
|
||||||
thread = new Thread(() -> readLoop(callback), "AudioCapture");
|
thread = new Thread(() -> readLoop(callback), "AudioCapture");
|
||||||
thread.start();
|
thread.start();
|
||||||
}
|
}
|
||||||
@@ -69,6 +72,8 @@ public class AudioCapture {
|
|||||||
if (read > 0) {
|
if (read > 0) {
|
||||||
long ptsUs = (System.nanoTime() - startNs) / 1000;
|
long ptsUs = (System.nanoTime() - startNs) / 1000;
|
||||||
callback.onPcm(buf, read, ptsUs);
|
callback.onPcm(buf, read, ptsUs);
|
||||||
|
} else if (read < 0) {
|
||||||
|
Log.w(TAG, "Audio read error: " + read);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
public final class CastSendPump {
|
public final class CastSendPump {
|
||||||
private static final String TAG = "CastSendPump";
|
private static final String TAG = "CastSendPump";
|
||||||
private static final int MAX_QUEUE = 48;
|
private static final int MAX_QUEUE = 48;
|
||||||
private static final int MAX_P_FRAMES_BACKLOG = 36;
|
|
||||||
|
|
||||||
private final LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
|
private final LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
|
||||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
@@ -68,21 +67,20 @@ public final class CastSendPump {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
||||||
if (keyFrame) {
|
if (!queue.offer(item)) {
|
||||||
queue.offer(item);
|
if (!keyFrame) {
|
||||||
return;
|
Outgoing dropped = queue.poll();
|
||||||
}
|
if (dropped != null && !dropped.keyFrame) {
|
||||||
if (queue.size() >= MAX_P_FRAMES_BACKLOG) {
|
|
||||||
droppedPFrames++;
|
droppedPFrames++;
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
if (!queue.offer(item)) {
|
if (!queue.offer(item)) {
|
||||||
queue.poll();
|
if (!keyFrame) {
|
||||||
if (!queue.offer(item)) {
|
|
||||||
droppedPFrames++;
|
droppedPFrames++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void enqueue(byte type, byte[] payload) {
|
public void enqueue(byte type, byte[] payload) {
|
||||||
if (!running.get()) {
|
if (!running.get()) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
import android.media.MediaCodecInfo;
|
||||||
|
import android.media.MediaCodecList;
|
||||||
|
import android.media.MediaFormat;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Discovers device video encoder/decoder MIME types. */
|
||||||
|
public final class CodecCatalog {
|
||||||
|
private CodecCatalog() {}
|
||||||
|
|
||||||
|
public static List<CastSettings.VideoCodec> supportedChoices() {
|
||||||
|
List<CastSettings.VideoCodec> out = new ArrayList<>();
|
||||||
|
out.add(CastSettings.VideoCodec.AUTO);
|
||||||
|
Set<String> enc = new LinkedHashSet<>(localEncoderMimes());
|
||||||
|
if (enc.contains(MediaFormat.MIMETYPE_VIDEO_AVC)) {
|
||||||
|
out.add(CastSettings.VideoCodec.H264);
|
||||||
|
}
|
||||||
|
if (enc.contains(MediaFormat.MIMETYPE_VIDEO_HEVC)) {
|
||||||
|
out.add(CastSettings.VideoCodec.H265);
|
||||||
|
}
|
||||||
|
if (enc.contains(MediaFormat.MIMETYPE_VIDEO_VP8) || enc.contains(MediaFormat.MIMETYPE_VIDEO_VP9)) {
|
||||||
|
// UI lists AUTO; VP8/VP9 chosen via negotiation when both sides support
|
||||||
|
}
|
||||||
|
out.add(CastSettings.VideoCodec.PASSTHROUGH);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> localEncoderMimes() {
|
||||||
|
return listCodecMimes(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> localDecoderMimes() {
|
||||||
|
return listCodecMimes(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> listCodecMimes(boolean encoder) {
|
||||||
|
Set<String> out = new LinkedHashSet<>();
|
||||||
|
String[] want = {
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_VP9,
|
||||||
|
MediaFormat.MIMETYPE_VIDEO_VP8,
|
||||||
|
};
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
|
||||||
|
for (MediaCodecInfo info : list.getCodecInfos()) {
|
||||||
|
if (info.isEncoder() != encoder) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String type : info.getSupportedTypes()) {
|
||||||
|
for (String w : want) {
|
||||||
|
if (w.equalsIgnoreCase(type)) {
|
||||||
|
out.add(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int n = MediaCodecList.getCodecCount();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
|
||||||
|
if (info.isEncoder() != encoder) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String type : info.getSupportedTypes()) {
|
||||||
|
for (String w : want) {
|
||||||
|
if (w.equalsIgnoreCase(type)) {
|
||||||
|
out.add(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.isEmpty()) {
|
||||||
|
out.add(MediaFormat.MIMETYPE_VIDEO_AVC);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic keyframe requests so Mediatek/others don't stall on a static screen
|
||||||
|
* (encoder stops emitting; receiver hits idle overlay).
|
||||||
|
*/
|
||||||
|
public final class EncoderKeepalive {
|
||||||
|
private static final String TAG = "EncoderKeepalive";
|
||||||
|
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
private VideoEncoder encoder;
|
||||||
|
private boolean running;
|
||||||
|
|
||||||
|
private final Runnable tick = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (!running || encoder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
encoder.requestKeyframe();
|
||||||
|
Log.d(TAG, "Keepalive keyframe requested");
|
||||||
|
handler.postDelayed(this, CastConfig.KEYFRAME_KEEPALIVE_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public void start(VideoEncoder encoder) {
|
||||||
|
this.encoder = encoder;
|
||||||
|
if (running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
handler.postDelayed(tick, CastConfig.KEYFRAME_KEEPALIVE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
running = false;
|
||||||
|
handler.removeCallbacks(tick);
|
||||||
|
encoder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,10 @@ import android.view.Surface;
|
|||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastNotifications;
|
import com.foxx.androidcast.CastNotifications;
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.CastResolution;
|
import com.foxx.androidcast.CastResolution;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
import com.foxx.androidcast.CastTuningEngine;
|
||||||
import com.foxx.androidcast.ICastSenderService;
|
import com.foxx.androidcast.ICastSenderService;
|
||||||
import com.foxx.androidcast.ICastStatusCallback;
|
import com.foxx.androidcast.ICastStatusCallback;
|
||||||
import com.foxx.androidcast.IntentExtras;
|
import com.foxx.androidcast.IntentExtras;
|
||||||
@@ -31,6 +33,7 @@ import com.foxx.androidcast.network.CastProtocol;
|
|||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
|
||||||
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -61,6 +64,10 @@ public class ScreenCastService extends Service {
|
|||||||
private MediaProjection projection;
|
private MediaProjection projection;
|
||||||
private VirtualDisplay virtualDisplay;
|
private VirtualDisplay virtualDisplay;
|
||||||
private VideoEncoder videoEncoder;
|
private VideoEncoder videoEncoder;
|
||||||
|
private EncoderKeepalive encoderKeepalive;
|
||||||
|
private CastTuningEngine.EffectiveParams effectiveParams;
|
||||||
|
private long lastSignificantFrameMs;
|
||||||
|
private final SenderStreamMetrics senderMetrics = new SenderStreamMetrics();
|
||||||
private AudioEncoder audioEncoder;
|
private AudioEncoder audioEncoder;
|
||||||
private AudioCapture audioCapture;
|
private AudioCapture audioCapture;
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
@@ -159,7 +166,9 @@ public class ScreenCastService extends Service {
|
|||||||
updateStatus(getString(R.string.waiting_receiver, host, port));
|
updateStatus(getString(R.string.waiting_receiver, host, port));
|
||||||
connectWithRetry(host, port, settings);
|
connectWithRetry(host, port, settings);
|
||||||
updateStatus(getString(R.string.authenticating));
|
updateStatus(getString(R.string.authenticating));
|
||||||
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
|
CastSession.HandshakeResult handshake = session.clientHandshake(
|
||||||
|
deviceName != null ? deviceName : "Sender", pin, settings);
|
||||||
|
settings = handshake.settings;
|
||||||
networkFeedback = new NetworkFeedbackManager(session, true);
|
networkFeedback = new NetworkFeedbackManager(session, true);
|
||||||
PassThroughNetworkControlPlane plane =
|
PassThroughNetworkControlPlane plane =
|
||||||
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
(PassThroughNetworkControlPlane) networkFeedback.getPlane();
|
||||||
@@ -168,6 +177,7 @@ public class ScreenCastService extends Service {
|
|||||||
casting.set(true);
|
casting.set(true);
|
||||||
activeSettings = settings;
|
activeSettings = settings;
|
||||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
||||||
|
scheduleTuningTick();
|
||||||
while (!stopping.get()) {
|
while (!stopping.get()) {
|
||||||
try {
|
try {
|
||||||
if (networkFeedback != null) {
|
if (networkFeedback != null) {
|
||||||
@@ -288,48 +298,28 @@ public class ScreenCastService extends Service {
|
|||||||
DisplayMetrics metrics = new DisplayMetrics();
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||||
CastResolution.Size size = CastResolution.compute(metrics, settings);
|
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
|
||||||
setupVideoPipeline(size, settings);
|
setupVideoPipeline(size, settings);
|
||||||
registerDisplayListener();
|
registerDisplayListener();
|
||||||
|
|
||||||
if (settings.isAudioEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
startAudioPipeline(settings);
|
||||||
audioEncoder = new AudioEncoder();
|
|
||||||
audioEncoder.prepare(new AudioEncoder.Callback() {
|
|
||||||
private boolean configSent;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onConfig(int sampleRate, int channels, byte[] csd0) {
|
|
||||||
if (configSent || stopping.get()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
configSent = true;
|
|
||||||
sendSafe(CastProtocol.MSG_AUDIO_CONFIG,
|
|
||||||
() -> CastProtocol.audioConfigPayload(sampleRate, channels, csd0));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onEncodedFrame(long ptsUs, byte[] frameData) {
|
|
||||||
sendSafe(CastProtocol.MSG_AUDIO_FRAME,
|
|
||||||
() -> CastProtocol.audioFramePayload(ptsUs, frameData));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
audioCapture = new AudioCapture();
|
|
||||||
audioCapture.start(projection, (pcm, length, ptsUs) -> {
|
|
||||||
if (audioEncoder != null) {
|
|
||||||
audioEncoder.queuePcm(pcm, length, ptsUs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String mode = session.getTransport().getModeLabel();
|
String mode = session.getTransport().getModeLabel();
|
||||||
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
|
updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException {
|
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException {
|
||||||
|
if (settings.isPassthrough()) {
|
||||||
|
throw new IOException("Passthrough video is not implemented yet (debug only)");
|
||||||
|
}
|
||||||
if (virtualDisplay != null) {
|
if (virtualDisplay != null) {
|
||||||
virtualDisplay.release();
|
virtualDisplay.release();
|
||||||
virtualDisplay = null;
|
virtualDisplay = null;
|
||||||
}
|
}
|
||||||
|
if (encoderKeepalive != null) {
|
||||||
|
encoderKeepalive.stop();
|
||||||
|
encoderKeepalive = null;
|
||||||
|
}
|
||||||
if (videoEncoder != null) {
|
if (videoEncoder != null) {
|
||||||
videoEncoder.stop();
|
videoEncoder.stop();
|
||||||
videoEncoder = null;
|
videoEncoder = null;
|
||||||
@@ -339,6 +329,16 @@ public class ScreenCastService extends Service {
|
|||||||
captureHeight = size.height;
|
captureHeight = size.height;
|
||||||
captureDensity = size.densityDpi;
|
captureDensity = size.densityDpi;
|
||||||
|
|
||||||
|
NetworkStatsSnapshot peer = null;
|
||||||
|
if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||||
|
peer = ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).getLastPeerStats();
|
||||||
|
}
|
||||||
|
effectiveParams = CastTuningEngine.resolve(settings, peer);
|
||||||
|
String mime = settings.getNegotiatedVideoMime();
|
||||||
|
if (mime == null || mime.isEmpty()) {
|
||||||
|
throw new IOException("No negotiated video codec");
|
||||||
|
}
|
||||||
|
|
||||||
videoEncoder = new VideoEncoder();
|
videoEncoder = new VideoEncoder();
|
||||||
VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
|
VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
|
||||||
@Override
|
@Override
|
||||||
@@ -352,13 +352,24 @@ public class ScreenCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||||
if (sendPump != null) {
|
if (sendPump == null || frameData == null) {
|
||||||
sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
|
return;
|
||||||
}
|
}
|
||||||
|
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
|
||||||
|
&& !keyFrame
|
||||||
|
&& frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
|
||||||
|
if (tiny) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSignificantFrameMs = System.currentTimeMillis();
|
||||||
|
senderMetrics.onEncodedFrame(keyFrame);
|
||||||
|
sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, settings, videoCb);
|
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
|
||||||
|
encoderKeepalive = new EncoderKeepalive();
|
||||||
|
encoderKeepalive.start(videoEncoder);
|
||||||
virtualDisplay = projection.createVirtualDisplay(
|
virtualDisplay = projection.createVirtualDisplay(
|
||||||
"AndroidCast",
|
"AndroidCast",
|
||||||
captureWidth,
|
captureWidth,
|
||||||
@@ -409,7 +420,7 @@ public class ScreenCastService extends Service {
|
|||||||
DisplayMetrics metrics = new DisplayMetrics();
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||||
wm.getDefaultDisplay().getRealMetrics(metrics);
|
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||||
CastResolution.Size size = CastResolution.compute(metrics, activeSettings);
|
CastResolution.Size size = CastResolution.compute(metrics, activeSettings, getPeerStats());
|
||||||
if (size.width == captureWidth && size.height == captureHeight) {
|
if (size.width == captureWidth && size.height == captureHeight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -421,8 +432,125 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private NetworkStatsSnapshot getPeerStats() {
|
||||||
|
if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
|
||||||
|
return ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).getLastPeerStats();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleTuningTick() {
|
||||||
|
mainHandler.postDelayed(() -> {
|
||||||
|
if (!casting.get() || stopping.get() || activeSettings == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NetworkStatsSnapshot peer = getPeerStats();
|
||||||
|
PassThroughNetworkControlPlane plane = networkFeedback != null
|
||||||
|
&& networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane
|
||||||
|
? (PassThroughNetworkControlPlane) networkFeedback.getPlane() : null;
|
||||||
|
if (plane != null) {
|
||||||
|
NetworkStatsSnapshot local = plane.buildLocalStats(System.currentTimeMillis());
|
||||||
|
senderMetrics.onNetworkSample(local.sendBitrateKbps,
|
||||||
|
peer != null ? peer.rttMs : 0);
|
||||||
|
}
|
||||||
|
CastTuningEngine.EffectiveParams next = CastTuningEngine.resolve(activeSettings, peer);
|
||||||
|
long idleMs = lastSignificantFrameMs > 0
|
||||||
|
? System.currentTimeMillis() - lastSignificantFrameMs : 0;
|
||||||
|
if (idleMs > CastConfig.KEYFRAME_KEEPALIVE_MS && videoEncoder != null) {
|
||||||
|
videoEncoder.requestKeyframe();
|
||||||
|
}
|
||||||
|
if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) {
|
||||||
|
Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)");
|
||||||
|
}
|
||||||
|
effectiveParams = next;
|
||||||
|
maybeAdaptCaptureSize(peer);
|
||||||
|
String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null;
|
||||||
|
updateStatus(senderMetrics.format(mime, captureWidth, captureHeight, sendPump));
|
||||||
|
scheduleTuningTick();
|
||||||
|
}, 2_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) {
|
||||||
|
if (activeSettings == null
|
||||||
|
|| activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
|
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||||
|
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||||
|
CastResolution.Size size = CastResolution.compute(metrics, activeSettings, peer);
|
||||||
|
int dw = Math.abs(size.width - captureWidth);
|
||||||
|
int dh = Math.abs(size.height - captureHeight);
|
||||||
|
if (dw < Math.max(32, captureWidth / 10) && dh < Math.max(32, captureHeight / 10)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setupVideoPipeline(size, activeSettings);
|
||||||
|
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "Adaptive resize failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startAudioPipeline(CastSettings settings) {
|
||||||
|
if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
Log.i(TAG, "Audio capture disabled for this session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (projection == null) {
|
||||||
|
Log.w(TAG, "Audio skipped — no MediaProjection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (audioEncoder != null) {
|
||||||
|
audioEncoder.stop();
|
||||||
|
}
|
||||||
|
if (audioCapture != null) {
|
||||||
|
audioCapture.stop();
|
||||||
|
}
|
||||||
|
audioEncoder = new AudioEncoder();
|
||||||
|
audioEncoder.prepare(new AudioEncoder.Callback() {
|
||||||
|
private boolean configSent;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConfig(int sampleRate, int channels, byte[] csd0) {
|
||||||
|
if (configSent || stopping.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configSent = true;
|
||||||
|
Log.i(TAG, "Sending audio config " + sampleRate + "Hz");
|
||||||
|
sendSafe(CastProtocol.MSG_AUDIO_CONFIG,
|
||||||
|
() -> CastProtocol.audioConfigPayload(sampleRate, channels, csd0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEncodedFrame(long ptsUs, byte[] frameData) {
|
||||||
|
if (frameData == null || frameData.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendSafe(CastProtocol.MSG_AUDIO_FRAME,
|
||||||
|
() -> CastProtocol.audioFramePayload(ptsUs, frameData));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
audioCapture = new AudioCapture();
|
||||||
|
audioCapture.start(projection, (pcm, length, ptsUs) -> {
|
||||||
|
if (audioEncoder != null) {
|
||||||
|
audioEncoder.queuePcm(pcm, length, ptsUs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Log.i(TAG, "Audio pipeline started (playback capture)");
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.e(TAG, "Audio pipeline failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handleAudioConsent(boolean accept) {
|
private void handleAudioConsent(boolean accept) {
|
||||||
if (!accept) {
|
if (accept) {
|
||||||
|
if (audioEncoder == null && activeSettings != null) {
|
||||||
|
startAudioPipeline(activeSettings);
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Receiver accepted audio");
|
||||||
|
} else {
|
||||||
if (audioCapture != null) {
|
if (audioCapture != null) {
|
||||||
audioCapture.stop();
|
audioCapture.stop();
|
||||||
audioCapture = null;
|
audioCapture = null;
|
||||||
@@ -475,6 +603,10 @@ public class ScreenCastService extends Service {
|
|||||||
virtualDisplay.release();
|
virtualDisplay.release();
|
||||||
virtualDisplay = null;
|
virtualDisplay = null;
|
||||||
}
|
}
|
||||||
|
if (encoderKeepalive != null) {
|
||||||
|
encoderKeepalive.stop();
|
||||||
|
encoderKeepalive = null;
|
||||||
|
}
|
||||||
if (videoEncoder != null) {
|
if (videoEncoder != null) {
|
||||||
videoEncoder.stop();
|
videoEncoder.stop();
|
||||||
videoEncoder = null;
|
videoEncoder = null;
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import android.content.Intent;
|
|||||||
import android.media.projection.MediaProjectionManager;
|
import android.media.projection.MediaProjectionManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.ListView;
|
import android.widget.ListView;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
@@ -26,13 +30,35 @@ import com.foxx.androidcast.discovery.DiscoveryManager;
|
|||||||
|
|
||||||
public class SenderActivity extends AppCompatActivity {
|
public class SenderActivity extends AppCompatActivity {
|
||||||
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
private static final int REQUEST_MEDIA_PROJECTION = 2001;
|
||||||
|
private static final long DISCOVERY_INTERVAL_MS = 30_000;
|
||||||
|
private static final int DEV_TAP_UNLOCK = 7;
|
||||||
|
|
||||||
private final CastSettings castSettings = new CastSettings();
|
private final CastSettings castSettings = new CastSettings();
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
private DiscoveryManager discovery;
|
private DiscoveryManager discovery;
|
||||||
private DeviceListAdapter deviceAdapter;
|
private DeviceListAdapter deviceAdapter;
|
||||||
|
|
||||||
private EditText pinInput;
|
|
||||||
private TextView statusText;
|
private TextView statusText;
|
||||||
|
private TextView titleText;
|
||||||
|
private ProgressBar discoveryProgress;
|
||||||
|
private View advancedPanel;
|
||||||
|
private EditText pinInput;
|
||||||
|
private EditText manualHostInput;
|
||||||
|
private EditText manualPortInput;
|
||||||
|
|
||||||
|
private int titleTapCount;
|
||||||
|
private long lastTitleTapMs;
|
||||||
|
private boolean useManualConnect;
|
||||||
|
|
||||||
|
private final Runnable discoveryWatchdog = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (discovery != null) {
|
||||||
|
restartDiscoveryInternal(false);
|
||||||
|
}
|
||||||
|
handler.postDelayed(this, DISCOVERY_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
@@ -41,15 +67,40 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
PermissionHelper.request(this);
|
PermissionHelper.request(this);
|
||||||
SettingsUi.bind(this, castSettings);
|
SettingsUi.bind(this, castSettings);
|
||||||
|
|
||||||
|
titleText = findViewById(R.id.text_sender_title);
|
||||||
pinInput = findViewById(R.id.edit_pin);
|
pinInput = findViewById(R.id.edit_pin);
|
||||||
pinInput.setText(CastConfig.DEFAULT_PIN);
|
pinInput.setText(CastConfig.DEFAULT_PIN);
|
||||||
statusText = findViewById(R.id.text_status);
|
statusText = findViewById(R.id.text_status);
|
||||||
|
discoveryProgress = findViewById(R.id.progress_discovery);
|
||||||
|
advancedPanel = findViewById(R.id.panel_advanced);
|
||||||
|
manualHostInput = findViewById(R.id.edit_manual_host);
|
||||||
|
manualPortInput = findViewById(R.id.edit_manual_port);
|
||||||
|
|
||||||
|
titleText.setOnClickListener(v -> {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastTitleTapMs > 2000) {
|
||||||
|
titleTapCount = 0;
|
||||||
|
}
|
||||||
|
lastTitleTapMs = now;
|
||||||
|
titleTapCount++;
|
||||||
|
if (titleTapCount >= DEV_TAP_UNLOCK) {
|
||||||
|
titleTapCount = 0;
|
||||||
|
advancedPanel.setVisibility(
|
||||||
|
advancedPanel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE);
|
||||||
|
Toast.makeText(this,
|
||||||
|
advancedPanel.getVisibility() == View.VISIBLE
|
||||||
|
? R.string.advanced_unlocked : R.string.advanced_hidden,
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ListView listView = findViewById(R.id.list_devices);
|
ListView listView = findViewById(R.id.list_devices);
|
||||||
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
||||||
deviceAdapter = new DeviceListAdapter(this);
|
deviceAdapter = new DeviceListAdapter(this);
|
||||||
listView.setAdapter(deviceAdapter);
|
listView.setAdapter(deviceAdapter);
|
||||||
listView.setOnItemClickListener((parent, view, position, id) -> {
|
listView.setOnItemClickListener((parent, view, position, id) -> {
|
||||||
deviceAdapter.setSelectedPosition(position);
|
deviceAdapter.setSelectedPosition(position);
|
||||||
|
useManualConnect = false;
|
||||||
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
return;
|
return;
|
||||||
@@ -63,6 +114,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
discovery = new DiscoveryManager(this);
|
discovery = new DiscoveryManager(this);
|
||||||
discovery.setListener(deviceList -> {
|
discovery.setListener(deviceList -> {
|
||||||
|
discoveryProgress.setVisibility(View.GONE);
|
||||||
deviceAdapter.setDevices(deviceList);
|
deviceAdapter.setDevices(deviceList);
|
||||||
statusText.setText(deviceList.isEmpty()
|
statusText.setText(deviceList.isEmpty()
|
||||||
? getString(R.string.searching)
|
? getString(R.string.searching)
|
||||||
@@ -72,16 +124,22 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
|
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
|
||||||
findViewById(R.id.btn_start_cast).setOnClickListener(v ->
|
findViewById(R.id.btn_start_cast).setOnClickListener(v ->
|
||||||
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
|
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
|
||||||
|
findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
|
||||||
|
useManualConnect = true;
|
||||||
|
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
restartDiscovery();
|
restartDiscovery();
|
||||||
|
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStop() {
|
protected void onStop() {
|
||||||
|
handler.removeCallbacks(discoveryWatchdog);
|
||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
}
|
}
|
||||||
@@ -89,8 +147,15 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void restartDiscovery() {
|
private void restartDiscovery() {
|
||||||
|
handler.removeCallbacks(discoveryWatchdog);
|
||||||
|
restartDiscoveryInternal(true);
|
||||||
|
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void restartDiscoveryInternal(boolean userRefresh) {
|
||||||
deviceAdapter.clearDevices();
|
deviceAdapter.clearDevices();
|
||||||
statusText.setText(R.string.searching);
|
statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
|
||||||
|
discoveryProgress.setVisibility(View.VISIBLE);
|
||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
discovery.clearDevices();
|
discovery.clearDevices();
|
||||||
@@ -99,6 +164,32 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void startCastFlow() {
|
private void startCastFlow() {
|
||||||
|
if (castSettings.isPassthrough()) {
|
||||||
|
Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String pin = pinInput.getText().toString().trim();
|
||||||
|
if (TextUtils.isEmpty(pin)) {
|
||||||
|
Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useManualConnect) {
|
||||||
|
String host = manualHostInput.getText().toString().trim();
|
||||||
|
String portStr = manualPortInput.getText().toString().trim();
|
||||||
|
if (TextUtils.isEmpty(host) || TextUtils.isEmpty(portStr)) {
|
||||||
|
Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingHost = host;
|
||||||
|
try {
|
||||||
|
pendingPort = Integer.parseInt(portStr);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingDeviceName = host;
|
||||||
|
} else {
|
||||||
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
|
||||||
@@ -108,17 +199,20 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String pin = pinInput.getText().toString().trim();
|
pendingHost = selected.host;
|
||||||
if (TextUtils.isEmpty(pin)) {
|
pendingPort = selected.port;
|
||||||
Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show();
|
pendingDeviceName = selected.name;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingPin = pin;
|
pendingPin = pin;
|
||||||
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||||
startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION);
|
startActivityForResult(mgr.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String pendingPin;
|
private String pendingPin;
|
||||||
|
private String pendingHost;
|
||||||
|
private int pendingPort;
|
||||||
|
private String pendingDeviceName;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
@@ -126,8 +220,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DiscoveryManager.DiscoveredDevice selected = deviceAdapter.getSelectedDevice();
|
if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) {
|
||||||
if (resultCode != Activity.RESULT_OK || data == null || selected == null) {
|
|
||||||
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -139,10 +232,10 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
Intent serviceIntent = new Intent(this, ScreenCastService.class);
|
Intent serviceIntent = new Intent(this, ScreenCastService.class);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data));
|
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data));
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, selected.host);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, selected.port);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, deviceName);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
|
||||||
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
|
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
/** Sender-side encode/send counters for notification diagnostics. */
|
||||||
|
public final class SenderStreamMetrics {
|
||||||
|
private long encodedFrames;
|
||||||
|
private long keyFrames;
|
||||||
|
private long windowFrames;
|
||||||
|
private long windowStartMs;
|
||||||
|
private float lastSecondFps;
|
||||||
|
private int lastSendKbps;
|
||||||
|
private int peerRttMs;
|
||||||
|
|
||||||
|
public synchronized void onEncodedFrame(boolean keyFrame) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
encodedFrames++;
|
||||||
|
if (keyFrame) {
|
||||||
|
keyFrames++;
|
||||||
|
}
|
||||||
|
if (windowStartMs == 0) {
|
||||||
|
windowStartMs = now;
|
||||||
|
}
|
||||||
|
if (now - windowStartMs >= 1000) {
|
||||||
|
lastSecondFps = windowFrames;
|
||||||
|
windowFrames = 0;
|
||||||
|
windowStartMs = now;
|
||||||
|
}
|
||||||
|
windowFrames++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void onNetworkSample(int sendKbps, int rttMs) {
|
||||||
|
if (sendKbps > 0) {
|
||||||
|
lastSendKbps = sendKbps;
|
||||||
|
}
|
||||||
|
if (rttMs > 0) {
|
||||||
|
peerRttMs = rttMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized String format(String videoCodec, int captureW, int captureH, CastSendPump pump) {
|
||||||
|
float fps = lastSecondFps > 0 ? lastSecondFps : 0f;
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (videoCodec != null && !videoCodec.isEmpty()) {
|
||||||
|
sb.append(com.foxx.androidcast.media.CodecNegotiator.displayName(videoCodec)).append(' ');
|
||||||
|
}
|
||||||
|
sb.append(captureW).append('x').append(captureH);
|
||||||
|
sb.append(" · enc ").append(fps > 0 ? String.format("%.0f", fps) : "?").append(" fps");
|
||||||
|
if (lastSendKbps > 0) {
|
||||||
|
sb.append(" · tx ").append(lastSendKbps).append(" kbps");
|
||||||
|
}
|
||||||
|
if (peerRttMs > 0) {
|
||||||
|
sb.append(" · rtt ").append(peerRttMs).append("ms");
|
||||||
|
}
|
||||||
|
if (pump != null) {
|
||||||
|
int dropped = pump.getDroppedPFrames();
|
||||||
|
int q = pump.getQueueDepth();
|
||||||
|
if (dropped > 0 || q > 8) {
|
||||||
|
sb.append(" · q").append(q);
|
||||||
|
if (dropped > 0) {
|
||||||
|
sb.append(" drop").append(dropped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,14 @@ import android.os.Bundle;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastTuningEngine;
|
||||||
import com.foxx.androidcast.CastSettings;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
/** H.264 encoder using MediaCodec (AVC), tuned for low-latency screen cast. */
|
/** Hardware video encoder (H.264 / H.265) via MediaCodec. */
|
||||||
public class VideoEncoder {
|
public class VideoEncoder {
|
||||||
private static final String TAG = "VideoEncoder";
|
private static final String TAG = "VideoEncoder";
|
||||||
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
|
|
||||||
|
|
||||||
public interface Callback {
|
public interface Callback {
|
||||||
void onConfig(int width, int height, byte[] csd0, byte[] csd1);
|
void onConfig(int width, int height, byte[] csd0, byte[] csd1);
|
||||||
@@ -32,24 +30,27 @@ public class VideoEncoder {
|
|||||||
private int height;
|
private int height;
|
||||||
private boolean running;
|
private boolean running;
|
||||||
private Thread drainThread;
|
private Thread drainThread;
|
||||||
|
private String mime;
|
||||||
|
|
||||||
public Surface prepare(int width, int height, CastSettings settings, Callback callback) throws IOException {
|
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||||
|
Callback callback) throws IOException {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
|
this.mime = mime;
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
|
|
||||||
MediaFormat tuned = buildFormat(width, height, settings, true);
|
MediaFormat tuned = buildFormat(width, height, mime, params, true);
|
||||||
MediaFormat basic = buildFormat(width, height, settings, false);
|
MediaFormat basic = buildFormat(width, height, mime, params, false);
|
||||||
|
|
||||||
codec = MediaCodec.createEncoderByType(MIME);
|
codec = MediaCodec.createEncoderByType(mime);
|
||||||
if (!tryConfigure(codec, tuned)) {
|
if (!tryConfigure(codec, tuned)) {
|
||||||
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
|
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
|
||||||
codec.release();
|
codec.release();
|
||||||
codec = MediaCodec.createEncoderByType(MIME);
|
codec = MediaCodec.createEncoderByType(mime);
|
||||||
if (!tryConfigure(codec, basic)) {
|
if (!tryConfigure(codec, basic)) {
|
||||||
codec.release();
|
codec.release();
|
||||||
codec = null;
|
codec = null;
|
||||||
throw new IOException("H.264 encoder configure failed for " + width + "x" + height);
|
throw new IOException("Encoder configure failed for " + mime + " " + width + "x" + height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,18 +59,19 @@ public class VideoEncoder {
|
|||||||
running = true;
|
running = true;
|
||||||
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
||||||
drainThread.start();
|
drainThread.start();
|
||||||
Log.i(TAG, "Encoder " + width + "x" + height + " @ " + settings.getVideoFps() + "fps "
|
Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps "
|
||||||
+ (settings.getVideoBitrate() / 1000) + "kbps");
|
+ (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec);
|
||||||
return inputSurface;
|
return inputSurface;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MediaFormat buildFormat(int width, int height, CastSettings settings, boolean tuned) {
|
private static MediaFormat buildFormat(int width, int height, String mime,
|
||||||
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
|
CastTuningEngine.EffectiveParams params, boolean tuned) {
|
||||||
|
MediaFormat format = MediaFormat.createVideoFormat(mime, width, height);
|
||||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
||||||
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate());
|
format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate);
|
||||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps());
|
format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps);
|
||||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL);
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec);
|
||||||
if (!tuned) {
|
if (!tuned || !params.requestLowLatency) {
|
||||||
return format;
|
return format;
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
@@ -80,7 +82,7 @@ public class VideoEncoder {
|
|||||||
format.setInteger(MediaFormat.KEY_PRIORITY, 0);
|
format.setInteger(MediaFormat.KEY_PRIORITY, 0);
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
format.setInteger(MediaFormat.KEY_OPERATING_RATE, settings.getVideoFps());
|
format.setInteger(MediaFormat.KEY_OPERATING_RATE, params.videoFps);
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
|
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
android:id="@+id/text_sender_title"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/sender_title"
|
android:text="@string/sender_title"
|
||||||
@@ -25,6 +26,50 @@
|
|||||||
|
|
||||||
<include layout="@layout/activity_settings_panel" />
|
<include layout="@layout/activity_settings_panel" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/panel_advanced"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/advanced_connect_title"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/edit_manual_host"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/manual_host_hint"
|
||||||
|
android:inputType="text" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/edit_manual_port"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/manual_port_hint"
|
||||||
|
android:inputType="number"
|
||||||
|
android:text="41235" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/edit_gateway_url"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:enabled="false"
|
||||||
|
android:hint="@string/gateway_url_hint"
|
||||||
|
android:inputType="textUri" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_manual_cast"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/manual_cast" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/edit_pin"
|
android:id="@+id/edit_pin"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -45,14 +90,28 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<Button
|
<FrameLayout
|
||||||
android:id="@+id/btn_refresh"
|
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_refresh"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/refresh" />
|
android:text="@string/refresh" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progress_discovery"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/btn_start_cast"
|
android:id="@+id/btn_start_cast"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
@@ -22,6 +22,39 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content" />
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/label_video_codec" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_video_codec"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/label_adjustment_preset" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_adjustment"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/label_bitrate_mode" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_bitrate_mode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -33,6 +66,17 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content" />
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/label_network_adoption" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_network_adoption"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -28,7 +28,26 @@
|
|||||||
<string name="resolution_full">Full display</string>
|
<string name="resolution_full">Full display</string>
|
||||||
<string name="resolution_75">75% scale</string>
|
<string name="resolution_75">75% scale</string>
|
||||||
<string name="resolution_half">50% scale</string>
|
<string name="resolution_half">50% scale</string>
|
||||||
<string name="resolution_720p">720p (max 1280×720)</string>
|
<string name="resolution_720p">720p height cap (keeps aspect)</string>
|
||||||
|
<string name="resolution_fixed_1080p">Fixed 1080p (fit in 1920×1080)</string>
|
||||||
|
<string name="resolution_fixed_720p">Fixed 720p (fit in 1280×720)</string>
|
||||||
|
<string name="resolution_adaptive">Adaptive (BW estimator, preview)</string>
|
||||||
|
<string name="label_video_codec">Video codec</string>
|
||||||
|
<string name="label_adjustment_preset">Cast adjustment preset</string>
|
||||||
|
<string name="label_bitrate_mode">Bitrate</string>
|
||||||
|
<string name="label_network_adoption">Network adoption</string>
|
||||||
|
<string name="option_auto">AUTO (default)</string>
|
||||||
|
<string name="option_none">None</string>
|
||||||
|
<string name="option_quality">Quality</string>
|
||||||
|
<string name="option_speed">Speed</string>
|
||||||
|
<string name="option_performance">Performance (balanced)</string>
|
||||||
|
<string name="option_balanced">Balanced</string>
|
||||||
|
<string name="option_robust_less">RobustLess (UDP only)</string>
|
||||||
|
<string name="option_estimated">Estimated</string>
|
||||||
|
<string name="codec_h264">H.264 (AVC)</string>
|
||||||
|
<string name="codec_h265">H.265 (HEVC)</string>
|
||||||
|
<string name="codec_passthrough">Passthrough (debug)</string>
|
||||||
|
<string name="codec_passthrough_unavailable">Passthrough video is not implemented yet</string>
|
||||||
<string name="transport_mismatch">Transport must match the receiver (TCP/UDP)</string>
|
<string name="transport_mismatch">Transport must match the receiver (TCP/UDP)</string>
|
||||||
<string name="select_receiver">Select a receiver first</string>
|
<string name="select_receiver">Select a receiver first</string>
|
||||||
<string name="enter_pin">Enter PIN</string>
|
<string name="enter_pin">Enter PIN</string>
|
||||||
@@ -37,6 +56,16 @@
|
|||||||
<string name="found_receivers">Found %1$d receiver(s)</string>
|
<string name="found_receivers">Found %1$d receiver(s)</string>
|
||||||
<string name="receiver_ready">Ready — PIN %1$s · %2$s</string>
|
<string name="receiver_ready">Ready — PIN %1$s · %2$s</string>
|
||||||
<string name="casting_from">Casting from %1$s</string>
|
<string name="casting_from">Casting from %1$s</string>
|
||||||
|
<string name="casting_from_codec">Casting from %1$s · %2$s</string>
|
||||||
|
<string name="rescanning">Rescanning for receivers…</string>
|
||||||
|
<string name="advanced_unlocked">Advanced options unlocked</string>
|
||||||
|
<string name="advanced_hidden">Advanced options hidden</string>
|
||||||
|
<string name="advanced_connect_title">Advanced connect (dev)</string>
|
||||||
|
<string name="manual_host_hint">Receiver IP address</string>
|
||||||
|
<string name="manual_port_hint">Port (default 41235)</string>
|
||||||
|
<string name="gateway_url_hint">Relay gateway URL (not implemented)</string>
|
||||||
|
<string name="manual_cast">Connect to IP</string>
|
||||||
|
<string name="manual_connect_missing">Enter host and port</string>
|
||||||
<string name="playing_resolution">Playing %1$dx%2$d</string>
|
<string name="playing_resolution">Playing %1$dx%2$d</string>
|
||||||
<string name="stop">Stop</string>
|
<string name="stop">Stop</string>
|
||||||
<string name="label_receiver_audio">Play incoming cast audio on this device</string>
|
<string name="label_receiver_audio">Play incoming cast audio on this device</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user