mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:39:09 +03:00
snap
This commit is contained in:
@@ -29,10 +29,17 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
aidl true
|
aidl true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.includeAndroidResources = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||||
|
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
|
||||||
|
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package com.foxx.androidcast;
|
|||||||
/** Shared constants for the LAN cast POC. */
|
/** Shared constants for the LAN cast POC. */
|
||||||
public final class CastConfig {
|
public final class CastConfig {
|
||||||
public static final int DISCOVERY_PORT = 41234;
|
public static final int DISCOVERY_PORT = 41234;
|
||||||
/** One UDP browse pass duration (progress bar on sender). */
|
/** One UDP browse pass duration (sender shows countdown on Refresh). */
|
||||||
public static final long DISCOVERY_SCAN_PASS_MS = 4_000;
|
public static final long DISCOVERY_SCAN_PASS_MS = 30_000;
|
||||||
|
/** Idle gap between automatic browse passes. */
|
||||||
|
public static final long DISCOVERY_PAUSE_MS = 30_000;
|
||||||
|
/** How often the sender broadcasts a discover probe during a scan pass. */
|
||||||
|
public static final long DISCOVERY_PROBE_INTERVAL_MS = 1_000;
|
||||||
public static final int CAST_PORT = 41235;
|
public static final int CAST_PORT = 41235;
|
||||||
public static final String DISCOVERY_MAGIC = "ACAST1";
|
public static final String DISCOVERY_MAGIC = "ACAST1";
|
||||||
|
|
||||||
@@ -16,6 +20,19 @@ public final class CastConfig {
|
|||||||
|
|
||||||
public static final String TRANSPORT_TCP = "tcp";
|
public static final String TRANSPORT_TCP = "tcp";
|
||||||
public static final String TRANSPORT_UDP = "udp";
|
public static final String TRANSPORT_UDP = "udp";
|
||||||
|
public static final String TRANSPORT_QUIC = "quic";
|
||||||
|
public static final String TRANSPORT_WEBRTC = "webrtc";
|
||||||
|
/** Default cast transport (UDP); TCP/QUIC remain selectable in settings. */
|
||||||
|
public static final String DEFAULT_TRANSPORT = TRANSPORT_UDP;
|
||||||
|
/** Dedicated port for experimental QUIC transport (UDP/TCP framed). */
|
||||||
|
public static final int QUIC_PORT = 41236;
|
||||||
|
|
||||||
|
public static int portForTransport(String transport) {
|
||||||
|
if (TRANSPORT_QUIC.equals(transport)) {
|
||||||
|
return QUIC_PORT;
|
||||||
|
}
|
||||||
|
return CAST_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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;
|
||||||
@@ -28,7 +45,7 @@ public final class CastConfig {
|
|||||||
|
|
||||||
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 = 96_000;
|
||||||
|
|
||||||
/** Max UDP payload chunk (fits typical Wi‑Fi MTU with header). */
|
/** Max UDP payload chunk (fits typical Wi‑Fi MTU with header). */
|
||||||
public static final int UDP_CHUNK_SIZE = 1200;
|
public static final int UDP_CHUNK_SIZE = 1200;
|
||||||
@@ -36,6 +53,9 @@ public final class CastConfig {
|
|||||||
/** Show diagnostics overlay on receiver playback (debug). */
|
/** Show diagnostics overlay on receiver playback (debug). */
|
||||||
public static final boolean SHOW_CAST_DIAGNOSTICS = true;
|
public static final boolean SHOW_CAST_DIAGNOSTICS = true;
|
||||||
|
|
||||||
|
/** Max retained session stat JSON files (room for one new file on next session). */
|
||||||
|
public static final int SESSION_STATS_MAX_FILES = 20;
|
||||||
|
|
||||||
/** No video packets for this long → treat stream as stopped (robot overlay). */
|
/** No video packets for this long → treat stream as stopped (robot overlay). */
|
||||||
/** No inbound video this long → robot overlay (keep above ~2× slowest expected frame interval). */
|
/** No inbound video this long → robot overlay (keep above ~2× slowest expected frame interval). */
|
||||||
public static final long STREAM_IDLE_TIMEOUT_MS = 8_000;
|
public static final long STREAM_IDLE_TIMEOUT_MS = 8_000;
|
||||||
|
|||||||
@@ -70,4 +70,9 @@ public final class CastResolution {
|
|||||||
private static int even(int v) {
|
private static int even(int v) {
|
||||||
return Math.max(2, v & ~1);
|
return Math.max(2, v & ~1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fixed 16:9 landscape chart size for calibration test mode (receiver letterboxing). */
|
||||||
|
public static Size calibrationTestSize() {
|
||||||
|
return new Size(960, 540, 240);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,14 @@ public class CastSettings implements Serializable {
|
|||||||
/** Whole display (default). */
|
/** Whole display (default). */
|
||||||
FULL_SCREEN,
|
FULL_SCREEN,
|
||||||
/** Android 14+: system picker can target a single app (better app audio capture). */
|
/** Android 14+: system picker can target a single app (better app audio capture). */
|
||||||
USER_CHOICE
|
USER_CHOICE,
|
||||||
|
/** Generated TV calibration pattern (no MediaProjection). */
|
||||||
|
CALIBRATION_TEST,
|
||||||
|
/**
|
||||||
|
* Future: Camera2 preview as video source (not MediaProjection app picker).
|
||||||
|
* {@link PlatformCastSupport#isCameraCaptureModeSupported()}
|
||||||
|
*/
|
||||||
|
CAMERA
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Resolution {
|
public enum Resolution {
|
||||||
@@ -69,7 +76,7 @@ public class CastSettings implements Serializable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String transport = CastConfig.TRANSPORT_TCP;
|
private String transport = CastConfig.DEFAULT_TRANSPORT;
|
||||||
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;
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import androidx.appcompat.app.AppCompatActivity;
|
|||||||
|
|
||||||
import com.foxx.androidcast.receiver.ReceiverActivity;
|
import com.foxx.androidcast.receiver.ReceiverActivity;
|
||||||
import com.foxx.androidcast.sender.SenderActivity;
|
import com.foxx.androidcast.sender.SenderActivity;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||||
|
|
||||||
public class MainActivity extends AppCompatActivity {
|
public class MainActivity extends AppCompatActivity {
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
SessionStatsStore.pruneOnAppStart(this);
|
||||||
setContentView(R.layout.activity_main);
|
setContentView(R.layout.activity_main);
|
||||||
|
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||||
PermissionHelper.request(this);
|
PermissionHelper.request(this);
|
||||||
|
|
||||||
Button sendBtn = findViewById(R.id.btn_send);
|
Button sendBtn = findViewById(R.id.btn_send);
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ public final class PlatformCastSupport {
|
|||||||
: CastSettings.CaptureMode.FULL_SCREEN;
|
: CastSettings.CaptureMode.FULL_SCREEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camera2 as a video source is separate from MediaProjection (app picker cannot select camera only).
|
||||||
|
*/
|
||||||
|
public static boolean isCameraCaptureModeSupported() {
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
|
||||||
|
}
|
||||||
|
|
||||||
/** Applies default audio flag: on for Android 10+, off otherwise. */
|
/** Applies default audio flag: on for Android 10+, off otherwise. */
|
||||||
public static void applyDefaultAudioSetting(CastSettings settings) {
|
public static void applyDefaultAudioSetting(CastSettings settings) {
|
||||||
settings.setAudioEnabled(isAudioCaptureSupported());
|
settings.setAudioEnabled(isAudioCaptureSupported());
|
||||||
|
|||||||
@@ -50,10 +50,36 @@ public final class SettingsUi {
|
|||||||
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
Spinner resolution = activity.findViewById(R.id.spinner_resolution);
|
||||||
|
|
||||||
bindSpinner(activity, transport, new String[]{
|
bindSpinner(activity, transport, new String[]{
|
||||||
|
activity.getString(R.string.transport_udp),
|
||||||
activity.getString(R.string.transport_tcp),
|
activity.getString(R.string.transport_tcp),
|
||||||
activity.getString(R.string.transport_udp)
|
activity.getString(R.string.transport_quic),
|
||||||
}, position -> settings.setTransport(
|
activity.getString(R.string.transport_webrtc)
|
||||||
position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP));
|
}, position -> {
|
||||||
|
switch (position) {
|
||||||
|
case 1:
|
||||||
|
settings.setTransport(CastConfig.TRANSPORT_TCP);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
settings.setTransport(CastConfig.TRANSPORT_QUIC);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
settings.setTransport(CastConfig.TRANSPORT_WEBRTC);
|
||||||
|
break;
|
||||||
|
case 0:
|
||||||
|
default:
|
||||||
|
settings.setTransport(CastConfig.TRANSPORT_UDP);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
int transportIndex = 0;
|
||||||
|
if (CastConfig.TRANSPORT_TCP.equals(settings.getTransport())) {
|
||||||
|
transportIndex = 1;
|
||||||
|
} else if (CastConfig.TRANSPORT_QUIC.equals(settings.getTransport())) {
|
||||||
|
transportIndex = 2;
|
||||||
|
} else if (CastConfig.TRANSPORT_WEBRTC.equals(settings.getTransport())) {
|
||||||
|
transportIndex = 3;
|
||||||
|
}
|
||||||
|
transport.setSelection(transportIndex);
|
||||||
|
|
||||||
List<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
|
List<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
|
||||||
List<String> codecLabels = new ArrayList<>();
|
List<String> codecLabels = new ArrayList<>();
|
||||||
@@ -106,29 +132,42 @@ public final class SettingsUi {
|
|||||||
if (captureMode == null) {
|
if (captureMode == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
java.util.List<String> modes = new java.util.ArrayList<>();
|
||||||
|
java.util.List<CastSettings.CaptureMode> modeValues = new java.util.ArrayList<>();
|
||||||
|
modes.add(activity.getString(R.string.capture_full_screen));
|
||||||
|
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
|
||||||
if (PlatformCastSupport.isCaptureUserChoiceSupported()) {
|
if (PlatformCastSupport.isCaptureUserChoiceSupported()) {
|
||||||
bindSpinner(activity, captureMode, new String[]{
|
modes.add(activity.getString(R.string.capture_user_choice));
|
||||||
activity.getString(R.string.capture_full_screen),
|
modeValues.add(CastSettings.CaptureMode.USER_CHOICE);
|
||||||
activity.getString(R.string.capture_user_choice)
|
|
||||||
}, position -> settings.setCaptureMode(
|
|
||||||
position == 1 ? CastSettings.CaptureMode.USER_CHOICE : CastSettings.CaptureMode.FULL_SCREEN));
|
|
||||||
captureMode.setSelection(settings.getCaptureMode() == CastSettings.CaptureMode.USER_CHOICE ? 1 : 0);
|
|
||||||
captureMode.setEnabled(true);
|
|
||||||
if (label != null) {
|
|
||||||
label.setText(R.string.label_capture_mode);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
String unavailable = activity.getString(R.string.capture_user_choice_unavailable,
|
modes.add(activity.getString(R.string.capture_user_choice_unavailable, Build.VERSION.SDK_INT));
|
||||||
Build.VERSION.SDK_INT);
|
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
|
||||||
bindSpinner(activity, captureMode, new String[]{
|
}
|
||||||
activity.getString(R.string.capture_full_screen),
|
modes.add(activity.getString(R.string.capture_calibration_test));
|
||||||
unavailable
|
modeValues.add(CastSettings.CaptureMode.CALIBRATION_TEST);
|
||||||
}, position -> settings.setCaptureMode(CastSettings.CaptureMode.FULL_SCREEN));
|
if (PlatformCastSupport.isCameraCaptureModeSupported()) {
|
||||||
captureMode.setSelection(0);
|
modes.add(activity.getString(R.string.capture_camera));
|
||||||
captureMode.setEnabled(false);
|
modeValues.add(CastSettings.CaptureMode.CAMERA);
|
||||||
if (label != null) {
|
} else {
|
||||||
label.setText(activity.getString(R.string.label_capture_mode_sender_only));
|
modes.add(activity.getString(R.string.capture_camera_unavailable));
|
||||||
|
modeValues.add(CastSettings.CaptureMode.FULL_SCREEN);
|
||||||
|
}
|
||||||
|
bindSpinner(activity, captureMode, modes.toArray(new String[0]), position -> {
|
||||||
|
if (position >= 0 && position < modeValues.size()) {
|
||||||
|
settings.setCaptureMode(modeValues.get(position));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
int sel = 0;
|
||||||
|
for (int i = 0; i < modeValues.size(); i++) {
|
||||||
|
if (modeValues.get(i) == settings.getCaptureMode()) {
|
||||||
|
sel = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
captureMode.setSelection(sel);
|
||||||
|
captureMode.setEnabled(true);
|
||||||
|
if (label != null) {
|
||||||
|
label.setText(R.string.label_capture_mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ public final class CastDiagnosticsFormatter {
|
|||||||
|
|
||||||
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
|
html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0));
|
||||||
html.append("<br/>");
|
html.append("<br/>");
|
||||||
|
|
||||||
|
appendUdpLossSection(html, peer, lastLocal);
|
||||||
|
appendProtectionSection(html, peer, lastLocal);
|
||||||
|
|
||||||
html.append("Ctrl: ").append(escape(ctrl));
|
html.append("Ctrl: ").append(escape(ctrl));
|
||||||
html.append("<br/>");
|
html.append("<br/>");
|
||||||
html.append("Mode: ").append(escape(bitrateMode));
|
html.append("Mode: ").append(escape(bitrateMode));
|
||||||
@@ -261,6 +265,110 @@ public final class CastDiagnosticsFormatter {
|
|||||||
return html.toString();
|
return html.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer,
|
||||||
|
NetworkStatsSnapshot local) {
|
||||||
|
long rx = pick(peer, local, true, false);
|
||||||
|
long tx = pick(peer, local, true, true);
|
||||||
|
long lost = val(peer, local, p -> p.udpReassemblyLost, l -> l.udpReassemblyLost);
|
||||||
|
long invalid = val(peer, local, p -> p.udpDatagramsInvalid, l -> l.udpDatagramsInvalid);
|
||||||
|
long dup = val(peer, local, p -> p.udpFragmentsDuplicate, l -> l.udpFragmentsDuplicate);
|
||||||
|
long wireDrop = val(peer, local, p -> p.udpWireDropped, l -> l.udpWireDropped);
|
||||||
|
long skipKey = val(peer, local, p -> p.recvVideoSkippedKey, l -> l.recvVideoSkippedKey);
|
||||||
|
long vDecErr = val(peer, local, p -> p.recvVideoDecodeErrors, l -> l.recvVideoDecodeErrors);
|
||||||
|
long aDecErr = val(peer, local, p -> p.recvAudioDecodeErrors, l -> l.recvAudioDecodeErrors);
|
||||||
|
long aDrop = val(peer, local, p -> p.recvAudioQueueDrops, l -> l.recvAudioQueueDrops);
|
||||||
|
long dropAudio = val(peer, local, p -> (long) p.droppedAudioFrames, l -> (long) l.droppedAudioFrames);
|
||||||
|
|
||||||
|
if (rx == 0 && tx == 0 && lost == 0 && invalid == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
html.append("<font color='").append(COLOR_DIM).append("'>UDP:</font> ");
|
||||||
|
html.append("rx ").append(rx).append(" / tx ").append(tx).append(" dg");
|
||||||
|
if (lost > 0) {
|
||||||
|
html.append(" ").append(colored("reasm-lost " + lost, true));
|
||||||
|
}
|
||||||
|
if (invalid > 0) {
|
||||||
|
html.append(" ").append(colored("bad " + invalid, true));
|
||||||
|
}
|
||||||
|
if (dup > 0) {
|
||||||
|
html.append(" dup ").append(dup);
|
||||||
|
}
|
||||||
|
if (wireDrop > 0) {
|
||||||
|
html.append(" ").append(colored("sess-drop " + wireDrop, true));
|
||||||
|
}
|
||||||
|
html.append("<br/>");
|
||||||
|
if (skipKey > 0 || vDecErr > 0 || aDecErr > 0 || aDrop > 0 || dropAudio > 0) {
|
||||||
|
html.append("App drops: ");
|
||||||
|
if (skipKey > 0) {
|
||||||
|
html.append(colored("vid-wait-key " + skipKey, skipKey > 50));
|
||||||
|
}
|
||||||
|
if (vDecErr > 0) {
|
||||||
|
html.append(' ').append(colored("vid-dec " + vDecErr, true));
|
||||||
|
}
|
||||||
|
if (aDecErr > 0) {
|
||||||
|
html.append(' ').append(colored("aud-dec " + aDecErr, true));
|
||||||
|
}
|
||||||
|
if (aDrop > 0) {
|
||||||
|
html.append(' ').append(colored("aud-q " + aDrop, true));
|
||||||
|
}
|
||||||
|
if (dropAudio > 0) {
|
||||||
|
html.append(' ').append(colored("snd-aud " + dropAudio, true));
|
||||||
|
}
|
||||||
|
html.append("<br/>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendProtectionSection(StringBuilder html, NetworkStatsSnapshot peer,
|
||||||
|
NetworkStatsSnapshot local) {
|
||||||
|
String mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE");
|
||||||
|
html.append("<font color='").append(COLOR_DIM).append("'>Protect:</font> ");
|
||||||
|
html.append(escape(mode));
|
||||||
|
html.append(" · FEC <font color='").append(COLOR_DIM).append("'>stub (off)</font>");
|
||||||
|
html.append(" · NACK <font color='").append(COLOR_DIM).append("'>stub (off)</font>");
|
||||||
|
long nack = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests);
|
||||||
|
if (nack > 0) {
|
||||||
|
html.append(" nack=").append(nack);
|
||||||
|
}
|
||||||
|
html.append("<br/>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long pick(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
|
||||||
|
boolean preferLocal, boolean tx) {
|
||||||
|
if (preferLocal && local != null) {
|
||||||
|
return tx ? local.udpDatagramsTx : local.udpDatagramsRx;
|
||||||
|
}
|
||||||
|
if (peer != null) {
|
||||||
|
return tx ? peer.udpDatagramsTx : peer.udpDatagramsRx;
|
||||||
|
}
|
||||||
|
return local != null ? (tx ? local.udpDatagramsTx : local.udpDatagramsRx) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long val(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
|
||||||
|
java.util.function.Function<NetworkStatsSnapshot, Long> peerFn,
|
||||||
|
java.util.function.Function<NetworkStatsSnapshot, Long> localFn) {
|
||||||
|
long v = 0;
|
||||||
|
if (local != null) {
|
||||||
|
v = Math.max(v, localFn.apply(local));
|
||||||
|
}
|
||||||
|
if (peer != null) {
|
||||||
|
v = Math.max(v, peerFn.apply(peer));
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(NetworkStatsSnapshot peer, NetworkStatsSnapshot local,
|
||||||
|
java.util.function.Function<NetworkStatsSnapshot, String> peerFn,
|
||||||
|
java.util.function.Function<NetworkStatsSnapshot, String> localFn,
|
||||||
|
String def) {
|
||||||
|
if (local != null && localFn.apply(local) != null && !localFn.apply(local).isEmpty()) {
|
||||||
|
return localFn.apply(local);
|
||||||
|
}
|
||||||
|
if (peer != null && peerFn.apply(peer) != null && !peerFn.apply(peer).isEmpty()) {
|
||||||
|
return peerFn.apply(peer);
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<String> buildHints(float inFps, float renderFps, int bwInKbps,
|
private static List<String> buildHints(float inFps, float renderFps, int bwInKbps,
|
||||||
int rttMs, long videoPackets) {
|
int rttMs, long videoPackets) {
|
||||||
List<String> hints = new ArrayList<>();
|
List<String> hints = new ArrayList<>();
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ public final class CastDiagnosticsLabels {
|
|||||||
switch (mode) {
|
switch (mode) {
|
||||||
case USER_CHOICE:
|
case USER_CHOICE:
|
||||||
return "Choose app or screen (Android 14+)";
|
return "Choose app or screen (Android 14+)";
|
||||||
|
case CALIBRATION_TEST:
|
||||||
|
return "Calibration test pattern";
|
||||||
|
case CAMERA:
|
||||||
|
return "Camera (Camera2, planned)";
|
||||||
case FULL_SCREEN:
|
case FULL_SCREEN:
|
||||||
default:
|
default:
|
||||||
return "Entire screen";
|
return "Entire screen";
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ public class DiscoveryManager {
|
|||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isBrowsing() {
|
||||||
|
return running.get();
|
||||||
|
}
|
||||||
|
|
||||||
public void startBrowsing() {
|
public void startBrowsing() {
|
||||||
if (!running.compareAndSet(false, true)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
@@ -148,7 +152,13 @@ public class DiscoveryManager {
|
|||||||
sock.setSoTimeout(300);
|
sock.setSoTimeout(300);
|
||||||
byte[] buf = new byte[1024];
|
byte[] buf = new byte[1024];
|
||||||
long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS;
|
long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS;
|
||||||
|
long nextProbeMs = System.currentTimeMillis();
|
||||||
while (running.get() && System.currentTimeMillis() < deadline) {
|
while (running.get() && System.currentTimeMillis() < deadline) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now >= nextProbeMs) {
|
||||||
|
sendDiscoveryProbe(sock);
|
||||||
|
nextProbeMs = now + CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
|
||||||
|
}
|
||||||
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||||
try {
|
try {
|
||||||
sock.receive(packet);
|
sock.receive(packet);
|
||||||
@@ -200,26 +210,25 @@ public class DiscoveryManager {
|
|||||||
try (DatagramSocket sock = new DatagramSocket()) {
|
try (DatagramSocket sock = new DatagramSocket()) {
|
||||||
socket = sock;
|
socket = sock;
|
||||||
sock.setBroadcast(true);
|
sock.setBroadcast(true);
|
||||||
|
sock.setSoTimeout(400);
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
JSONObject json = new JSONObject();
|
sendAnnounce(sock, context, deviceName, port, transport, listening,
|
||||||
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
placeholderId, videoMask, audioMask);
|
||||||
json.put("name", deviceName);
|
long listenUntil = System.currentTimeMillis() + 2_000;
|
||||||
json.put("port", port);
|
while (running.get() && System.currentTimeMillis() < listenUntil) {
|
||||||
json.put("transport", transport);
|
try {
|
||||||
json.put("listening", listening);
|
byte[] buf = new byte[1024];
|
||||||
json.put("device_uuid", DeviceInfo.getDeviceUuid(context));
|
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||||
byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
|
sock.receive(packet);
|
||||||
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
|
if (isDiscoverProbe(packet)) {
|
||||||
byte[] wire;
|
sendAnnounce(sock, context, deviceName, port, transport, listening,
|
||||||
try {
|
placeholderId, videoMask, audioMask);
|
||||||
wire = CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes);
|
sendAnnounceUnicast(sock, context, deviceName, port, transport, listening,
|
||||||
} catch (Exception e) {
|
placeholderId, videoMask, audioMask, packet.getAddress());
|
||||||
wire = jsonBytes;
|
}
|
||||||
|
} catch (java.net.SocketTimeoutException ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
|
||||||
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
|
|
||||||
sock.send(packet);
|
|
||||||
Thread.sleep(2000);
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (running.get()) {
|
if (running.get()) {
|
||||||
@@ -232,6 +241,73 @@ public class DiscoveryManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void sendDiscoveryProbe(DatagramSocket sock) {
|
||||||
|
try {
|
||||||
|
JSONObject json = new JSONObject();
|
||||||
|
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
||||||
|
json.put("discover", true);
|
||||||
|
byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||||
|
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||||
|
sock.send(packet);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Discovery probe send failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDiscoverProbe(DatagramPacket packet) {
|
||||||
|
try {
|
||||||
|
String text = new String(packet.getData(), packet.getOffset(), packet.getLength(),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
JSONObject json = new JSONObject(text);
|
||||||
|
return CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))
|
||||||
|
&& json.optBoolean("discover", false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port,
|
||||||
|
String transport, boolean listening, int placeholderId, int videoMask, int audioMask)
|
||||||
|
throws Exception {
|
||||||
|
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
|
||||||
|
placeholderId, videoMask, audioMask);
|
||||||
|
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
|
||||||
|
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
|
||||||
|
sock.send(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port,
|
||||||
|
String transport, boolean listening, int placeholderId, int videoMask, int audioMask,
|
||||||
|
InetAddress target) {
|
||||||
|
try {
|
||||||
|
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
|
||||||
|
placeholderId, videoMask, audioMask);
|
||||||
|
DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT);
|
||||||
|
sock.send(packet);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Unicast announce failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport,
|
||||||
|
boolean listening, int placeholderId, int videoMask, int audioMask) throws Exception {
|
||||||
|
JSONObject json = new JSONObject();
|
||||||
|
json.put("magic", CastConfig.DISCOVERY_MAGIC);
|
||||||
|
json.put("name", deviceName);
|
||||||
|
json.put("port", port);
|
||||||
|
json.put("transport", transport);
|
||||||
|
json.put("listening", listening);
|
||||||
|
json.put("device_uuid", DeviceInfo.getDeviceUuid(context));
|
||||||
|
byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
|
||||||
|
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
|
||||||
|
try {
|
||||||
|
return CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return jsonBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handlePacket(DatagramPacket packet) {
|
private void handlePacket(DatagramPacket packet) {
|
||||||
try {
|
try {
|
||||||
byte[] raw = packet.getData();
|
byte[] raw = packet.getData();
|
||||||
@@ -274,7 +350,7 @@ public class DiscoveryManager {
|
|||||||
}
|
}
|
||||||
String name = json.optString("name", "Unknown");
|
String name = json.optString("name", "Unknown");
|
||||||
int port = json.optInt("port", CastConfig.CAST_PORT);
|
int port = json.optInt("port", CastConfig.CAST_PORT);
|
||||||
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
|
String transport = json.optString("transport", CastConfig.DEFAULT_TRANSPORT);
|
||||||
if (!json.optBoolean("listening", true)) {
|
if (!json.optBoolean("listening", true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,8 @@ public final class CodecNegotiator {
|
|||||||
public static boolean isH264(String mime) {
|
public static boolean isH264(String mime) {
|
||||||
return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime);
|
return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean isHevc(String mime) {
|
||||||
|
return MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import android.media.MediaFormat;
|
||||||
|
|
||||||
|
/** Inspects encoded bitstream headers for diagnostics. */
|
||||||
|
public final class EncodedStreamProbe {
|
||||||
|
private EncodedStreamProbe() {}
|
||||||
|
|
||||||
|
public static String describeKeyframe(String mime, byte[] annexB) {
|
||||||
|
if (annexB == null || annexB.length < 5) {
|
||||||
|
return "empty";
|
||||||
|
}
|
||||||
|
int offset = 0;
|
||||||
|
if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1) {
|
||||||
|
offset = 4;
|
||||||
|
} else if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 1) {
|
||||||
|
offset = 3;
|
||||||
|
}
|
||||||
|
if (offset >= annexB.length) {
|
||||||
|
return "no start code";
|
||||||
|
}
|
||||||
|
if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) {
|
||||||
|
int nalType = (annexB[offset] >> 1) & 0x3F;
|
||||||
|
return "HEVC NAL type " + nalType + " (" + annexB.length + " B)";
|
||||||
|
}
|
||||||
|
if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) {
|
||||||
|
int nalType = annexB[offset] & 0x1F;
|
||||||
|
return "AVC NAL type " + nalType + " (" + annexB.length + " B)";
|
||||||
|
}
|
||||||
|
return "encoded " + annexB.length + " B";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ package com.foxx.androidcast.media;
|
|||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
|
|
||||||
/** Converts H.264 AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. */
|
/**
|
||||||
|
* Converts H.264 / HEVC AVCC (length-prefixed) NAL units to Annex-B start codes for decoders.
|
||||||
|
* MediaCodec encoders on Android typically emit AVCC; many decoders expect Annex-B in input buffers.
|
||||||
|
*/
|
||||||
public final class H264Bitstream {
|
public final class H264Bitstream {
|
||||||
private static final byte[] START_CODE = {0, 0, 0, 1};
|
private static final byte[] START_CODE = {0, 0, 0, 1};
|
||||||
|
|
||||||
|
|||||||
52
app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java
Normal file
52
app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
/** ARGB bitmap to NV21 for YUV420 encoder input. */
|
||||||
|
public final class RgbToNv21 {
|
||||||
|
private RgbToNv21() {}
|
||||||
|
|
||||||
|
public static byte[] fromBitmap(Bitmap bitmap) {
|
||||||
|
int width = bitmap.getWidth();
|
||||||
|
int height = bitmap.getHeight();
|
||||||
|
int[] argb = new int[width * height];
|
||||||
|
bitmap.getPixels(argb, 0, width, 0, 0, width, height);
|
||||||
|
return fromArgb(argb, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] fromArgb(int[] argb, int width, int height) {
|
||||||
|
int frameSize = width * height;
|
||||||
|
int ySize = frameSize;
|
||||||
|
int uvSize = frameSize / 2;
|
||||||
|
byte[] nv21 = new byte[ySize + uvSize];
|
||||||
|
int yIndex = 0;
|
||||||
|
int uvIndex = ySize;
|
||||||
|
for (int j = 0; j < height; j++) {
|
||||||
|
for (int i = 0; i < width; i++) {
|
||||||
|
int color = argb[j * width + i];
|
||||||
|
int r = (color >> 16) & 0xff;
|
||||||
|
int g = (color >> 8) & 0xff;
|
||||||
|
int b = color & 0xff;
|
||||||
|
int y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
|
||||||
|
int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
|
||||||
|
int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
|
||||||
|
nv21[yIndex++] = (byte) clamp(y);
|
||||||
|
if ((j & 1) == 0 && (i & 1) == 0) {
|
||||||
|
nv21[uvIndex++] = (byte) clamp(v);
|
||||||
|
nv21[uvIndex++] = (byte) clamp(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nv21;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int v) {
|
||||||
|
if (v < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (v > 255) {
|
||||||
|
return 255;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java
Normal file
115
app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARGB to YUV420 planar (I420) or semi-planar (NV12) for MediaCodec buffer input.
|
||||||
|
*/
|
||||||
|
public final class RgbToYuv420 {
|
||||||
|
public enum Layout {
|
||||||
|
I420,
|
||||||
|
NV12
|
||||||
|
}
|
||||||
|
|
||||||
|
private RgbToYuv420() {}
|
||||||
|
|
||||||
|
public static byte[] fromBitmap(Bitmap bitmap, Layout layout) {
|
||||||
|
int width = bitmap.getWidth();
|
||||||
|
int height = bitmap.getHeight();
|
||||||
|
int[] argb = new int[width * height];
|
||||||
|
bitmap.getPixels(argb, 0, width, 0, 0, width, height);
|
||||||
|
return fromArgb(argb, width, height, layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] fromArgb(int[] argb, int width, int height, Layout layout) {
|
||||||
|
if (layout == Layout.NV12) {
|
||||||
|
return toNv12(argb, width, height);
|
||||||
|
}
|
||||||
|
return toI420(argb, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int bufferSize(int width, int height) {
|
||||||
|
return width * height + (width * height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] toI420(int[] argb, int width, int height) {
|
||||||
|
int frameSize = width * height;
|
||||||
|
int chromaW = (width + 1) / 2;
|
||||||
|
int chromaH = (height + 1) / 2;
|
||||||
|
int chromaLen = chromaW * chromaH;
|
||||||
|
byte[] out = new byte[frameSize + chromaLen * 2];
|
||||||
|
int uOffset = frameSize;
|
||||||
|
int vOffset = frameSize + chromaLen;
|
||||||
|
|
||||||
|
for (int j = 0; j < height; j++) {
|
||||||
|
for (int i = 0; i < width; i++) {
|
||||||
|
int color = argb[j * width + i];
|
||||||
|
int r = (color >> 16) & 0xff;
|
||||||
|
int g = (color >> 8) & 0xff;
|
||||||
|
int b = color & 0xff;
|
||||||
|
int y = yFromRgb(r, g, b);
|
||||||
|
out[j * width + i] = (byte) y;
|
||||||
|
if ((j & 1) == 0 && (i & 1) == 0) {
|
||||||
|
int u = uFromRgb(r, g, b);
|
||||||
|
int v = vFromRgb(r, g, b);
|
||||||
|
int cx = i >> 1;
|
||||||
|
int cy = j >> 1;
|
||||||
|
int cIndex = cy * chromaW + cx;
|
||||||
|
out[uOffset + cIndex] = (byte) u;
|
||||||
|
out[vOffset + cIndex] = (byte) v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] toNv12(int[] argb, int width, int height) {
|
||||||
|
int frameSize = width * height;
|
||||||
|
int chromaW = (width + 1) / 2;
|
||||||
|
int chromaH = (height + 1) / 2;
|
||||||
|
byte[] out = new byte[frameSize + chromaW * chromaH * 2];
|
||||||
|
int uvOffset = frameSize;
|
||||||
|
|
||||||
|
for (int j = 0; j < height; j++) {
|
||||||
|
for (int i = 0; i < width; i++) {
|
||||||
|
int color = argb[j * width + i];
|
||||||
|
int r = (color >> 16) & 0xff;
|
||||||
|
int g = (color >> 8) & 0xff;
|
||||||
|
int b = color & 0xff;
|
||||||
|
out[j * width + i] = (byte) yFromRgb(r, g, b);
|
||||||
|
if ((j & 1) == 0 && (i & 1) == 0) {
|
||||||
|
int u = uFromRgb(r, g, b);
|
||||||
|
int v = vFromRgb(r, g, b);
|
||||||
|
int cx = i >> 1;
|
||||||
|
int cy = j >> 1;
|
||||||
|
int uvIndex = uvOffset + (cy * chromaW + cx) * 2;
|
||||||
|
out[uvIndex] = (byte) u;
|
||||||
|
out[uvIndex + 1] = (byte) v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int yFromRgb(int r, int g, int b) {
|
||||||
|
return clamp(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int uFromRgb(int r, int g, int b) {
|
||||||
|
return clamp(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int vFromRgb(int r, int g, int b) {
|
||||||
|
return clamp(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(int v) {
|
||||||
|
if (v < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (v > 255) {
|
||||||
|
return 255;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,14 +24,22 @@ public final class CastPacketFramer {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static CastProtocol.Message unframe(byte[] wire) throws IOException {
|
public static CastProtoHeader readHeader(byte[] wire) throws IOException {
|
||||||
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) {
|
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1) {
|
||||||
throw new IOException("Frame too short");
|
throw new IOException("Frame too short");
|
||||||
}
|
}
|
||||||
CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length);
|
CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length);
|
||||||
if (!hdr.verifyChecksum()) {
|
if (!hdr.verifyChecksum()) {
|
||||||
throw new IOException("Header checksum mismatch");
|
throw new IOException("Header checksum mismatch");
|
||||||
}
|
}
|
||||||
|
return hdr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CastProtocol.Message unframe(byte[] wire) throws IOException {
|
||||||
|
if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) {
|
||||||
|
throw new IOException("Frame too short");
|
||||||
|
}
|
||||||
|
CastProtoHeader hdr = readHeader(wire);
|
||||||
int innerOff = hdr.headerSize;
|
int innerOff = hdr.headerSize;
|
||||||
if (wire.length < innerOff + 5) {
|
if (wire.length < innerOff + 5) {
|
||||||
throw new IOException("Missing message body");
|
throw new IOException("Missing message body");
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ public final class CastProtocol {
|
|||||||
return local.getTransport().equals(remote.getTransport());
|
return local.getTransport().equals(remote.getTransport());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final byte NETWORK_STATS_VERSION = 3;
|
private static final byte NETWORK_STATS_VERSION = 4;
|
||||||
|
|
||||||
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
|
||||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||||
@@ -283,6 +283,28 @@ public final class CastProtocol {
|
|||||||
dos.writeFloat(s.audioCapturePeakDb);
|
dos.writeFloat(s.audioCapturePeakDb);
|
||||||
dos.writeFloat(s.audioCaptureRmsDb);
|
dos.writeFloat(s.audioCaptureRmsDb);
|
||||||
}
|
}
|
||||||
|
if (NETWORK_STATS_VERSION >= 4) {
|
||||||
|
dos.writeLong(s.udpDatagramsRx);
|
||||||
|
dos.writeLong(s.udpDatagramsTx);
|
||||||
|
dos.writeLong(s.udpDatagramsInvalid);
|
||||||
|
dos.writeLong(s.udpFragmentsRx);
|
||||||
|
dos.writeLong(s.udpFragmentsDuplicate);
|
||||||
|
dos.writeLong(s.udpReassemblyOk);
|
||||||
|
dos.writeLong(s.udpReassemblyLost);
|
||||||
|
dos.writeLong(s.udpWireDropped);
|
||||||
|
dos.writeLong(s.udpMalformed);
|
||||||
|
dos.writeInt(s.droppedAudioFrames);
|
||||||
|
dos.writeLong(s.recvVideoPackets);
|
||||||
|
dos.writeLong(s.recvAudioPackets);
|
||||||
|
dos.writeLong(s.recvVideoSkippedKey);
|
||||||
|
dos.writeLong(s.recvVideoDecodeErrors);
|
||||||
|
dos.writeLong(s.recvAudioDecodeErrors);
|
||||||
|
dos.writeLong(s.recvAudioQueueDrops);
|
||||||
|
dos.writeLong(s.recvDamagedVideoFrames);
|
||||||
|
dos.writeUTF(s.protectionMode != null ? s.protectionMode : "NONE");
|
||||||
|
dos.writeLong(s.fecStubPackets);
|
||||||
|
dos.writeLong(s.nackStubRequests);
|
||||||
|
}
|
||||||
dos.flush();
|
dos.flush();
|
||||||
return bos.toByteArray();
|
return bos.toByteArray();
|
||||||
}
|
}
|
||||||
@@ -315,6 +337,28 @@ public final class CastProtocol {
|
|||||||
s.audioCapturePeakDb = dis.readFloat();
|
s.audioCapturePeakDb = dis.readFloat();
|
||||||
s.audioCaptureRmsDb = dis.readFloat();
|
s.audioCaptureRmsDb = dis.readFloat();
|
||||||
}
|
}
|
||||||
|
if (ver >= 4 && dis.available() > 0) {
|
||||||
|
s.udpDatagramsRx = dis.readLong();
|
||||||
|
s.udpDatagramsTx = dis.readLong();
|
||||||
|
s.udpDatagramsInvalid = dis.readLong();
|
||||||
|
s.udpFragmentsRx = dis.readLong();
|
||||||
|
s.udpFragmentsDuplicate = dis.readLong();
|
||||||
|
s.udpReassemblyOk = dis.readLong();
|
||||||
|
s.udpReassemblyLost = dis.readLong();
|
||||||
|
s.udpWireDropped = dis.readLong();
|
||||||
|
s.udpMalformed = dis.readLong();
|
||||||
|
s.droppedAudioFrames = dis.readInt();
|
||||||
|
s.recvVideoPackets = dis.readLong();
|
||||||
|
s.recvAudioPackets = dis.readLong();
|
||||||
|
s.recvVideoSkippedKey = dis.readLong();
|
||||||
|
s.recvVideoDecodeErrors = dis.readLong();
|
||||||
|
s.recvAudioDecodeErrors = dis.readLong();
|
||||||
|
s.recvAudioQueueDrops = dis.readLong();
|
||||||
|
s.recvDamagedVideoFrames = dis.readLong();
|
||||||
|
s.protectionMode = dis.readUTF();
|
||||||
|
s.fecStubPackets = dis.readLong();
|
||||||
|
s.nackStubRequests = dis.readLong();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
@@ -324,7 +368,7 @@ public final class CastProtocol {
|
|||||||
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(advice.suggestedBitrateKbps);
|
dos.writeInt(advice.suggestedBitrateKbps);
|
||||||
dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.TRANSPORT_TCP);
|
dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.DEFAULT_TRANSPORT);
|
||||||
dos.writeByte(advice.switchTransport ? 1 : 0);
|
dos.writeByte(advice.switchTransport ? 1 : 0);
|
||||||
dos.writeInt(advice.congestionLevel);
|
dos.writeInt(advice.congestionLevel);
|
||||||
dos.flush();
|
dos.flush();
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds to the first non-zero session id on a connection and drops stale packets from other casts.
|
||||||
|
*/
|
||||||
|
public final class CastSessionGate {
|
||||||
|
private static final int UNBOUND = Integer.MIN_VALUE;
|
||||||
|
|
||||||
|
private int boundSessionId = UNBOUND;
|
||||||
|
|
||||||
|
public boolean accept(CastProtoHeader header) {
|
||||||
|
if (header == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
int sid = header.sessionId;
|
||||||
|
if (sid == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (boundSessionId == UNBOUND) {
|
||||||
|
boundSessionId = sid;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return boundSessionId == sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset() {
|
||||||
|
boundSessionId = UNBOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBound() {
|
||||||
|
return boundSessionId != UNBOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBoundSessionId() {
|
||||||
|
return boundSessionId == UNBOUND ? 0 : boundSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.foxx.androidcast.network;
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
|
|
||||||
import java.io.Closeable;
|
import java.io.Closeable;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
@@ -7,6 +9,9 @@ import java.io.IOException;
|
|||||||
public interface CastTransport extends Closeable {
|
public interface CastTransport extends Closeable {
|
||||||
/** Optional proto header context; ignored when null. */
|
/** Optional proto header context; ignored when null. */
|
||||||
default void setFramingContext(CastFramingContext context) {}
|
default void setFramingContext(CastFramingContext context) {}
|
||||||
|
|
||||||
|
/** Drops inbound wire packets whose session id does not match the active cast. */
|
||||||
|
default void setInboundSessionGate(CastSessionGate gate) {}
|
||||||
/** Server: bind and wait for first peer datagram / connection. */
|
/** Server: bind and wait for first peer datagram / connection. */
|
||||||
void listen(int port) throws IOException;
|
void listen(int port) throws IOException;
|
||||||
|
|
||||||
@@ -28,6 +33,11 @@ public interface CastTransport extends Closeable {
|
|||||||
|
|
||||||
String getModeLabel();
|
String getModeLabel();
|
||||||
|
|
||||||
|
/** Loss/damage counters when supported (UDP); empty stats otherwise. */
|
||||||
|
default TransportLossStats getLossStats() {
|
||||||
|
return new TransportLossStats();
|
||||||
|
}
|
||||||
|
|
||||||
/** Server: drop the active client socket but keep the listen socket bound. */
|
/** Server: drop the active client socket but keep the listen socket bound. */
|
||||||
default void releaseConnection() {
|
default void releaseConnection() {
|
||||||
close();
|
close();
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
package com.foxx.androidcast.network;
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||||
|
import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
public final class CastTransportFactory {
|
public final class CastTransportFactory {
|
||||||
|
private static Context appContext;
|
||||||
|
|
||||||
private CastTransportFactory() {}
|
private CastTransportFactory() {}
|
||||||
|
|
||||||
|
public static void init(Context context) {
|
||||||
|
appContext = context.getApplicationContext();
|
||||||
|
}
|
||||||
|
|
||||||
public static CastTransport create(CastSettings settings) throws IOException {
|
public static CastTransport create(CastSettings settings) throws IOException {
|
||||||
if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) {
|
String mode = settings.getTransport();
|
||||||
|
if (CastConfig.TRANSPORT_QUIC.equals(mode)) {
|
||||||
|
if (appContext == null) {
|
||||||
|
throw new IOException("CastTransportFactory.init(context) required for QUIC");
|
||||||
|
}
|
||||||
|
return new QuicCronetCastTransport(appContext);
|
||||||
|
}
|
||||||
|
if (WebRtcTransportFactory.isTransportId(mode)) {
|
||||||
|
return WebRtcTransportFactory.create();
|
||||||
|
}
|
||||||
|
if (CastConfig.TRANSPORT_UDP.equals(mode)) {
|
||||||
return new UdpCastTransport();
|
return new UdpCastTransport();
|
||||||
}
|
}
|
||||||
return new TcpCastTransport();
|
return new TcpCastTransport();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.io.BufferedOutputStream;
|
|||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
@@ -18,6 +19,7 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
|
|
||||||
private final Object sendLock = new Object();
|
private final Object sendLock = new Object();
|
||||||
private CastFramingContext framingContext;
|
private CastFramingContext framingContext;
|
||||||
|
private CastSessionGate inboundSessionGate;
|
||||||
private final Object receiveLock = new Object();
|
private final Object receiveLock = new Object();
|
||||||
|
|
||||||
private ServerSocket serverSocket;
|
private ServerSocket serverSocket;
|
||||||
@@ -83,6 +85,11 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
this.framingContext = context;
|
this.framingContext = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setInboundSessionGate(CastSessionGate gate) {
|
||||||
|
this.inboundSessionGate = gate;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void send(byte type, byte[] payload) throws IOException {
|
public void send(byte type, byte[] payload) throws IOException {
|
||||||
synchronized (sendLock) {
|
synchronized (sendLock) {
|
||||||
@@ -133,7 +140,83 @@ public class TcpCastTransport implements CastTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private CastProtocol.Message readFramedMessage() throws IOException {
|
private CastProtocol.Message readFramedMessage() throws IOException {
|
||||||
return CastPacketFramer.readFramedMessage(bufferedIn);
|
if (inboundSessionGate == null) {
|
||||||
|
return CastPacketFramer.readFramedMessage(bufferedIn);
|
||||||
|
}
|
||||||
|
byte[] magicBuf = readFully(bufferedIn, 4);
|
||||||
|
if (magicBuf == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int magic = (magicBuf[0] & 0xff) | ((magicBuf[1] & 0xff) << 8)
|
||||||
|
| ((magicBuf[2] & 0xff) << 16) | ((magicBuf[3] & 0xff) << 24);
|
||||||
|
if (magic != CastProtoHeader.MAGIC) {
|
||||||
|
throw new IOException("Bad magic on wire: 0x" + Integer.toHexString(magic));
|
||||||
|
}
|
||||||
|
byte[] restHdr = readFully(bufferedIn, CastProtoHeader.HEADER_SIZE_V1 - 4);
|
||||||
|
if (restHdr == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] headerBytes = new byte[CastProtoHeader.HEADER_SIZE_V1];
|
||||||
|
System.arraycopy(magicBuf, 0, headerBytes, 0, 4);
|
||||||
|
System.arraycopy(restHdr, 0, headerBytes, 4, restHdr.length);
|
||||||
|
CastProtoHeader hdr = CastProtoHeader.decode(headerBytes, 0, headerBytes.length);
|
||||||
|
if (!hdr.verifyChecksum()) {
|
||||||
|
throw new IOException("Header checksum mismatch");
|
||||||
|
}
|
||||||
|
if (!inboundSessionGate.accept(hdr)) {
|
||||||
|
skipMessageBody(in);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte type = (byte) in.read();
|
||||||
|
if (type < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] lenBuf = readFully(bufferedIn, 4);
|
||||||
|
if (lenBuf == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
|
||||||
|
| ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
|
||||||
|
if (len < 0 || len > 4 * 1024 * 1024) {
|
||||||
|
throw new IOException("Invalid payload length: " + len);
|
||||||
|
}
|
||||||
|
byte[] payload = len == 0 ? new byte[0] : readFully(bufferedIn, len);
|
||||||
|
if (payload == null && len > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new CastProtocol.Message(type, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void skipMessageBody(DataInputStream in) throws IOException {
|
||||||
|
byte type = (byte) in.read();
|
||||||
|
if (type < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] lenBuf = readFully(in, 4);
|
||||||
|
if (lenBuf == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
|
||||||
|
| ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
|
||||||
|
if (len > 0 && len <= 4 * 1024 * 1024) {
|
||||||
|
in.skipBytes(len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readFully(InputStream in, int n) throws IOException {
|
||||||
|
byte[] buf = new byte[n];
|
||||||
|
int off = 0;
|
||||||
|
while (off < n) {
|
||||||
|
int r = in.read(buf, off, n - off);
|
||||||
|
if (r < 0) {
|
||||||
|
if (off == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new java.io.EOFException("Short read");
|
||||||
|
}
|
||||||
|
off += r;
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean waitForHeader(int timeoutMs) throws IOException {
|
private boolean waitForHeader(int timeoutMs) throws IOException {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.foxx.androidcast.network;
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
import com.foxx.androidcast.network.transport.StreamProtectionEngine;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportStatsProvider;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -14,7 +17,7 @@ import java.util.Map;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/** Best-effort message transport over UDP with fragmentation. */
|
/** Best-effort message transport over UDP with fragmentation. */
|
||||||
public class UdpCastTransport implements CastTransport {
|
public class UdpCastTransport implements CastTransport, TransportStatsProvider {
|
||||||
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
private static final byte[] MAGIC = {'A', 'C', 'U', 'D'};
|
||||||
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
|
/** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */
|
||||||
private static final int HEADER = 17;
|
private static final int HEADER = 17;
|
||||||
@@ -26,7 +29,11 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
private volatile boolean listening;
|
private volatile boolean listening;
|
||||||
|
|
||||||
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
|
||||||
|
private final TransportLossStats lossStats = new TransportLossStats();
|
||||||
|
private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop();
|
||||||
private CastFramingContext framingContext;
|
private CastFramingContext framingContext;
|
||||||
|
private CastSessionGate inboundSessionGate;
|
||||||
|
private static final long REASSEMBLY_TIMEOUT_MS = 3_000;
|
||||||
|
|
||||||
public boolean isListening() {
|
public boolean isListening() {
|
||||||
return listening && socket != null && !socket.isClosed();
|
return listening && socket != null && !socket.isClosed();
|
||||||
@@ -71,6 +78,24 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
this.framingContext = context;
|
this.framingContext = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setInboundSessionGate(CastSessionGate gate) {
|
||||||
|
this.inboundSessionGate = gate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select protection stub (default none). FEC/NACK are not active yet. */
|
||||||
|
public void setProtectionEngine(StreamProtectionEngine engine) {
|
||||||
|
protectionEngine = engine != null ? engine : StreamProtectionEngine.noop();
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.protectionMode = protectionEngine.mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TransportLossStats getLossStats() {
|
||||||
|
return lossStats.copy();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void send(byte type, byte[] payload) throws IOException {
|
public void send(byte type, byte[] payload) throws IOException {
|
||||||
if (peer == null) {
|
if (peer == null) {
|
||||||
@@ -85,6 +110,7 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
} else {
|
} else {
|
||||||
body = payload == null ? new byte[0] : payload;
|
body = payload == null ? new byte[0] : payload;
|
||||||
}
|
}
|
||||||
|
body = protectionEngine.onOutboundPayload(type, body);
|
||||||
if (body.length <= MAX_RELIABLE_BYTES) {
|
if (body.length <= MAX_RELIABLE_BYTES) {
|
||||||
sendOnce(wireType, body);
|
sendOnce(wireType, body);
|
||||||
sendOnce(wireType, body);
|
sendOnce(wireType, body);
|
||||||
@@ -103,6 +129,9 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
|
byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length);
|
||||||
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
|
DatagramPacket dp = new DatagramPacket(packet, packet.length, peer);
|
||||||
socket.send(dp);
|
socket.send(dp);
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.datagramsSent++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +142,16 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
}
|
}
|
||||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
expireStaleReassembly();
|
||||||
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
|
int remaining = (int) Math.max(1, deadline - System.currentTimeMillis());
|
||||||
socket.setSoTimeout(remaining);
|
socket.setSoTimeout(remaining);
|
||||||
try {
|
try {
|
||||||
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
|
byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64];
|
||||||
DatagramPacket dp = new DatagramPacket(buf, buf.length);
|
DatagramPacket dp = new DatagramPacket(buf, buf.length);
|
||||||
socket.receive(dp);
|
socket.receive(dp);
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.datagramsReceived++;
|
||||||
|
}
|
||||||
if (peer == null) {
|
if (peer == null) {
|
||||||
peer = new InetSocketAddress(dp.getAddress(), dp.getPort());
|
peer = new InetSocketAddress(dp.getAddress(), dp.getPort());
|
||||||
}
|
}
|
||||||
@@ -132,15 +165,38 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void expireStaleReassembly() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (Map.Entry<Integer, Reassembly> e : pending.entrySet()) {
|
||||||
|
Reassembly r = e.getValue();
|
||||||
|
if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) {
|
||||||
|
pending.remove(e.getKey());
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.reassemblyIncomplete++;
|
||||||
|
}
|
||||||
|
protectionEngine.onNackStub(e.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
|
private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException {
|
||||||
if (length < HEADER) {
|
if (length < HEADER) {
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.datagramsInvalid++;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < 4; i++) {
|
for (int i = 0; i < 4; i++) {
|
||||||
if (data[i] != MAGIC[i]) {
|
if (data[i] != MAGIC[i]) {
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.datagramsInvalid++;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.fragmentsReceived++;
|
||||||
|
}
|
||||||
byte type = data[4];
|
byte type = data[4];
|
||||||
int messageId = readInt(data, 5);
|
int messageId = readInt(data, 5);
|
||||||
int fragIndex = readShort(data, 9);
|
int fragIndex = readShort(data, 9);
|
||||||
@@ -152,32 +208,68 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
|
|
||||||
CastProtocol.Message raw;
|
CastProtocol.Message raw;
|
||||||
if (fragCount <= 1) {
|
if (fragCount <= 1) {
|
||||||
|
byte[] body;
|
||||||
if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) {
|
if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) {
|
||||||
byte[] trimmed = new byte[totalLen];
|
body = new byte[totalLen];
|
||||||
System.arraycopy(chunk, 0, trimmed, 0, totalLen);
|
System.arraycopy(chunk, 0, body, 0, totalLen);
|
||||||
raw = new CastProtocol.Message(type, trimmed);
|
|
||||||
} else {
|
} else {
|
||||||
raw = new CastProtocol.Message(type, chunk);
|
body = chunk;
|
||||||
}
|
}
|
||||||
|
body = protectionEngine.onInboundPayload(type, body);
|
||||||
|
raw = new CastProtocol.Message(type, body);
|
||||||
return unwrapWire(raw);
|
return unwrapWire(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
Reassembly r = pending.get(messageId);
|
Reassembly existing = pending.get(messageId);
|
||||||
if (r == null || r.fragCount != fragCount || r.totalLen != totalLen) {
|
if (existing != null && (existing.fragCount != fragCount || existing.totalLen != totalLen)) {
|
||||||
|
pending.remove(messageId);
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.reassemblySuperseded++;
|
||||||
|
}
|
||||||
|
existing = null;
|
||||||
|
}
|
||||||
|
Reassembly r = existing;
|
||||||
|
if (r == null) {
|
||||||
r = new Reassembly(fragCount, totalLen);
|
r = new Reassembly(fragCount, totalLen);
|
||||||
pending.put(messageId, r);
|
pending.put(messageId, r);
|
||||||
}
|
}
|
||||||
r.put(fragIndex, chunk, payloadLen);
|
if (!r.put(fragIndex, chunk, payloadLen)) {
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.fragmentsDuplicate++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!r.complete()) {
|
if (!r.complete()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
pending.remove(messageId);
|
pending.remove(messageId);
|
||||||
return unwrapWire(new CastProtocol.Message(type, r.assemble()));
|
synchronized (lossStats) {
|
||||||
|
lossStats.reassemblyCompleted++;
|
||||||
|
}
|
||||||
|
byte[] assembled = r.assemble();
|
||||||
|
assembled = protectionEngine.onInboundPayload(type, assembled);
|
||||||
|
return unwrapWire(new CastProtocol.Message(type, assembled));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
|
private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException {
|
||||||
if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
|
if (raw.type == CastProtocol.MSG_WIRE_PACKET) {
|
||||||
return CastPacketFramer.unframe(raw.payload);
|
if (inboundSessionGate != null) {
|
||||||
|
CastProtoHeader hdr = CastPacketFramer.readHeader(raw.payload);
|
||||||
|
if (!inboundSessionGate.accept(hdr)) {
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.wirePacketsDropped++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return CastPacketFramer.unframe(raw.payload);
|
||||||
|
} catch (IOException e) {
|
||||||
|
synchronized (lossStats) {
|
||||||
|
lossStats.malformedPackets++;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -242,6 +334,7 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
private static final class Reassembly {
|
private static final class Reassembly {
|
||||||
final int fragCount;
|
final int fragCount;
|
||||||
final int totalLen;
|
final int totalLen;
|
||||||
|
final long startedMs;
|
||||||
final byte[][] parts;
|
final byte[][] parts;
|
||||||
final int[] sizes;
|
final int[] sizes;
|
||||||
int received;
|
int received;
|
||||||
@@ -249,19 +342,21 @@ public class UdpCastTransport implements CastTransport {
|
|||||||
Reassembly(int fragCount, int totalLen) {
|
Reassembly(int fragCount, int totalLen) {
|
||||||
this.fragCount = fragCount;
|
this.fragCount = fragCount;
|
||||||
this.totalLen = totalLen;
|
this.totalLen = totalLen;
|
||||||
|
this.startedMs = System.currentTimeMillis();
|
||||||
this.parts = new byte[fragCount][];
|
this.parts = new byte[fragCount][];
|
||||||
this.sizes = new int[fragCount];
|
this.sizes = new int[fragCount];
|
||||||
}
|
}
|
||||||
|
|
||||||
void put(int index, byte[] data, int len) {
|
boolean put(int index, byte[] data, int len) {
|
||||||
if (index < 0 || index >= fragCount || parts[index] != null) {
|
if (index < 0 || index >= fragCount || parts[index] != null) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
byte[] copy = new byte[len];
|
byte[] copy = new byte[len];
|
||||||
System.arraycopy(data, 0, copy, 0, len);
|
System.arraycopy(data, 0, copy, 0, len);
|
||||||
parts[index] = copy;
|
parts[index] = copy;
|
||||||
sizes[index] = len;
|
sizes[index] = len;
|
||||||
received++;
|
received++;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean complete() {
|
boolean complete() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public class NetworkControlAdvice {
|
|||||||
public boolean sendStats;
|
public boolean sendStats;
|
||||||
public boolean sendControl;
|
public boolean sendControl;
|
||||||
public int suggestedBitrateKbps;
|
public int suggestedBitrateKbps;
|
||||||
public String suggestedTransport = CastConfig.TRANSPORT_TCP;
|
public String suggestedTransport = CastConfig.DEFAULT_TRANSPORT;
|
||||||
public boolean switchTransport;
|
public boolean switchTransport;
|
||||||
public int congestionLevel;
|
public int congestionLevel;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,28 @@ public class NetworkStatsSnapshot {
|
|||||||
public float audioCapturePeakDb;
|
public float audioCapturePeakDb;
|
||||||
public float audioCaptureRmsDb;
|
public float audioCaptureRmsDb;
|
||||||
|
|
||||||
|
/** v4: UDP + app-layer loss (see {@link com.foxx.androidcast.network.transport.TransportLossStats}). */
|
||||||
|
public long udpDatagramsRx;
|
||||||
|
public long udpDatagramsTx;
|
||||||
|
public long udpDatagramsInvalid;
|
||||||
|
public long udpFragmentsRx;
|
||||||
|
public long udpFragmentsDuplicate;
|
||||||
|
public long udpReassemblyOk;
|
||||||
|
public long udpReassemblyLost;
|
||||||
|
public long udpWireDropped;
|
||||||
|
public long udpMalformed;
|
||||||
|
public int droppedAudioFrames;
|
||||||
|
public long recvVideoPackets;
|
||||||
|
public long recvAudioPackets;
|
||||||
|
public long recvVideoSkippedKey;
|
||||||
|
public long recvVideoDecodeErrors;
|
||||||
|
public long recvAudioDecodeErrors;
|
||||||
|
public long recvAudioQueueDrops;
|
||||||
|
public long recvDamagedVideoFrames;
|
||||||
|
public String protectionMode = "NONE";
|
||||||
|
public long fecStubPackets;
|
||||||
|
public long nackStubRequests;
|
||||||
|
|
||||||
public NetworkStatsSnapshot copy() {
|
public NetworkStatsSnapshot copy() {
|
||||||
NetworkStatsSnapshot c = new NetworkStatsSnapshot();
|
NetworkStatsSnapshot c = new NetworkStatsSnapshot();
|
||||||
c.sequence = sequence;
|
c.sequence = sequence;
|
||||||
@@ -49,6 +71,26 @@ public class NetworkStatsSnapshot {
|
|||||||
c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks;
|
c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks;
|
||||||
c.audioCapturePeakDb = audioCapturePeakDb;
|
c.audioCapturePeakDb = audioCapturePeakDb;
|
||||||
c.audioCaptureRmsDb = audioCaptureRmsDb;
|
c.audioCaptureRmsDb = audioCaptureRmsDb;
|
||||||
|
c.udpDatagramsRx = udpDatagramsRx;
|
||||||
|
c.udpDatagramsTx = udpDatagramsTx;
|
||||||
|
c.udpDatagramsInvalid = udpDatagramsInvalid;
|
||||||
|
c.udpFragmentsRx = udpFragmentsRx;
|
||||||
|
c.udpFragmentsDuplicate = udpFragmentsDuplicate;
|
||||||
|
c.udpReassemblyOk = udpReassemblyOk;
|
||||||
|
c.udpReassemblyLost = udpReassemblyLost;
|
||||||
|
c.udpWireDropped = udpWireDropped;
|
||||||
|
c.udpMalformed = udpMalformed;
|
||||||
|
c.droppedAudioFrames = droppedAudioFrames;
|
||||||
|
c.recvVideoPackets = recvVideoPackets;
|
||||||
|
c.recvAudioPackets = recvAudioPackets;
|
||||||
|
c.recvVideoSkippedKey = recvVideoSkippedKey;
|
||||||
|
c.recvVideoDecodeErrors = recvVideoDecodeErrors;
|
||||||
|
c.recvAudioDecodeErrors = recvAudioDecodeErrors;
|
||||||
|
c.recvAudioQueueDrops = recvAudioQueueDrops;
|
||||||
|
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
|
||||||
|
c.protectionMode = protectionMode;
|
||||||
|
c.fecStubPackets = fecStubPackets;
|
||||||
|
c.nackStubRequests = nackStubRequests;
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane {
|
|||||||
outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS;
|
outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS;
|
||||||
outbound.sendControl = false;
|
outbound.sendControl = false;
|
||||||
outbound.suggestedBitrateKbps = 0;
|
outbound.suggestedBitrateKbps = 0;
|
||||||
outbound.suggestedTransport = CastConfig.TRANSPORT_TCP;
|
outbound.suggestedTransport = CastConfig.DEFAULT_TRANSPORT;
|
||||||
outbound.switchTransport = false;
|
outbound.switchTransport = false;
|
||||||
outbound.congestionLevel = 0;
|
outbound.congestionLevel = 0;
|
||||||
if (outbound.sendStats) {
|
if (outbound.sendStats) {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.foxx.androidcast.network.quic;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.google.android.gms.net.CronetProviderInstaller;
|
||||||
|
import com.google.android.gms.tasks.Tasks;
|
||||||
|
|
||||||
|
import org.chromium.net.CronetEngine;
|
||||||
|
import org.chromium.net.ExperimentalCronetEngine;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/** Shared Cronet engine with QUIC / HTTP3 enabled for experimental cast transport. */
|
||||||
|
public final class CronetEngineProvider {
|
||||||
|
private static final AtomicReference<CronetEngine> ENGINE = new AtomicReference<>();
|
||||||
|
|
||||||
|
private CronetEngineProvider() {}
|
||||||
|
|
||||||
|
public static CronetEngine getEngine(Context context) throws Exception {
|
||||||
|
CronetEngine existing = ENGINE.get();
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
synchronized (CronetEngineProvider.class) {
|
||||||
|
existing = ENGINE.get();
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
Tasks.await(CronetProviderInstaller.installProvider(app), 30, TimeUnit.SECONDS);
|
||||||
|
ExperimentalCronetEngine.Builder builder =
|
||||||
|
new ExperimentalCronetEngine.Builder(app)
|
||||||
|
.enableQuic(true)
|
||||||
|
.enableHttp2(true)
|
||||||
|
.setStoragePath(app.getCacheDir().getAbsolutePath())
|
||||||
|
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 256 * 1024);
|
||||||
|
CronetEngine built = builder.build();
|
||||||
|
ENGINE.set(built);
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void shutdown() {
|
||||||
|
CronetEngine e = ENGINE.getAndSet(null);
|
||||||
|
if (e != null) {
|
||||||
|
e.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.foxx.androidcast.network.quic;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
import com.foxx.androidcast.network.CastFramingContext;
|
||||||
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
|
import com.foxx.androidcast.network.TcpCastTransport;
|
||||||
|
import com.foxx.androidcast.network.UdpCastTransport;
|
||||||
|
|
||||||
|
import org.chromium.net.CronetEngine;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Experimental QUIC cast transport (Cronet stack).
|
||||||
|
* <p>
|
||||||
|
* Cronet on Android is a QUIC/HTTP3 <em>client</em> only. This transport uses:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link CronetEngine} with QUIC enabled (shared via {@link CronetEngineProvider})</li>
|
||||||
|
* <li>Framed cast messages on {@link CastConfig#QUIC_PORT} over UDP (same as legacy ACUD framing)</li>
|
||||||
|
* <li>TCP fallback on the same port if UDP bind fails</li>
|
||||||
|
* </ul>
|
||||||
|
* A future revision can move the data plane to Cronet {@code BidirectionalStream} once an HTTP/3
|
||||||
|
* terminator is available on the receiver.
|
||||||
|
*/
|
||||||
|
public final class QuicCronetCastTransport implements CastTransport {
|
||||||
|
private static final String TAG = "QuicCronetTransport";
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private final UdpCastTransport udp = new UdpCastTransport();
|
||||||
|
private final TcpCastTransport tcpFallback = new TcpCastTransport();
|
||||||
|
private final AtomicBoolean usingTcp = new AtomicBoolean(false);
|
||||||
|
private CronetEngine engine;
|
||||||
|
|
||||||
|
public QuicCronetCastTransport(Context context) {
|
||||||
|
this.appContext = context.getApplicationContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CastTransport active() {
|
||||||
|
return usingTcp.get() ? tcpFallback : udp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setFramingContext(CastFramingContext context) {
|
||||||
|
udp.setFramingContext(context);
|
||||||
|
tcpFallback.setFramingContext(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setInboundSessionGate(com.foxx.androidcast.network.CastSessionGate gate) {
|
||||||
|
udp.setInboundSessionGate(gate);
|
||||||
|
tcpFallback.setInboundSessionGate(gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureCronet() {
|
||||||
|
if (engine != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
engine = CronetEngineProvider.getEngine(appContext);
|
||||||
|
Log.i(TAG, "Cronet engine ready (QUIC/HTTP3 enabled for experimental transport)");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Cronet engine unavailable: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void prepareListen(int port) throws IOException {
|
||||||
|
ensureCronet();
|
||||||
|
int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
|
||||||
|
try {
|
||||||
|
udp.prepareListen(quicPort);
|
||||||
|
usingTcp.set(false);
|
||||||
|
Log.i(TAG, "QUIC listen UDP :" + quicPort);
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "QUIC UDP listen failed, TCP fallback :" + quicPort, e);
|
||||||
|
tcpFallback.prepareListen(quicPort);
|
||||||
|
usingTcp.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void acceptClient() throws IOException {
|
||||||
|
active().acceptClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void listen(int port) throws IOException {
|
||||||
|
prepareListen(port);
|
||||||
|
acceptClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connect(String host, int port) throws IOException {
|
||||||
|
ensureCronet();
|
||||||
|
int quicPort = port > 0 ? port : CastConfig.QUIC_PORT;
|
||||||
|
try {
|
||||||
|
udp.connect(host, quicPort);
|
||||||
|
usingTcp.set(false);
|
||||||
|
Log.i(TAG, "QUIC connect UDP " + host + ":" + quicPort);
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "QUIC UDP connect failed, TCP fallback", e);
|
||||||
|
tcpFallback.connect(host, quicPort);
|
||||||
|
usingTcp.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(byte type, byte[] payload) throws IOException {
|
||||||
|
active().send(type, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||||
|
return active().receive(timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModeLabel() {
|
||||||
|
String base = usingTcp.get() ? "QUIC(exp/TCP)" : "QUIC(exp/UDP)";
|
||||||
|
return engine != null ? base + "+Cronet" : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void releaseConnection() {
|
||||||
|
active().releaseConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
udp.close();
|
||||||
|
tcpFallback.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for future FEC / NACK over UDP. Current builds use {@link #noop()} only.
|
||||||
|
*/
|
||||||
|
public interface StreamProtectionEngine {
|
||||||
|
StreamProtectionMode mode();
|
||||||
|
|
||||||
|
/** Called before UDP payload is fragmented (outbound). */
|
||||||
|
byte[] onOutboundPayload(byte type, byte[] payload);
|
||||||
|
|
||||||
|
/** Called when a reassembled UDP message is ready (inbound). */
|
||||||
|
byte[] onInboundPayload(byte type, byte[] payload);
|
||||||
|
|
||||||
|
/** Future: process FEC repair block (stub). */
|
||||||
|
default void onFecBlockStub(byte[] block) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Future: sender handles NACK for messageId (stub). */
|
||||||
|
default void onNackStub(int messageId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static StreamProtectionEngine noop() {
|
||||||
|
return new StreamProtectionEngine() {
|
||||||
|
@Override
|
||||||
|
public StreamProtectionMode mode() {
|
||||||
|
return StreamProtectionMode.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] onInboundPayload(byte type, byte[] payload) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placeholder for upcoming FEC — counts invocations only. */
|
||||||
|
static StreamProtectionEngine fecStub(TransportLossStats stats) {
|
||||||
|
return new StreamProtectionEngine() {
|
||||||
|
@Override
|
||||||
|
public StreamProtectionMode mode() {
|
||||||
|
return StreamProtectionMode.FEC_STUB;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||||
|
if (stats != null) {
|
||||||
|
synchronized (stats) {
|
||||||
|
stats.fecPacketsStub++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] onInboundPayload(byte type, byte[] payload) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placeholder for upcoming NACK — no retransmit yet. */
|
||||||
|
static StreamProtectionEngine nackStub(TransportLossStats stats) {
|
||||||
|
return new StreamProtectionEngine() {
|
||||||
|
@Override
|
||||||
|
public StreamProtectionMode mode() {
|
||||||
|
return StreamProtectionMode.NACK_STUB;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] onOutboundPayload(byte type, byte[] payload) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] onInboundPayload(byte type, byte[] payload) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNackStub(int messageId) {
|
||||||
|
if (stats != null) {
|
||||||
|
synchronized (stats) {
|
||||||
|
stats.nackRequestsStub++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
|
/** Planned LAN cast reliability features (stubs until implemented). */
|
||||||
|
public enum StreamProtectionMode {
|
||||||
|
NONE,
|
||||||
|
/** Forward-error correction over UDP (not implemented). */
|
||||||
|
FEC_STUB,
|
||||||
|
/** Negative acknowledgement / retransmit requests (not implemented). */
|
||||||
|
NACK_STUB;
|
||||||
|
|
||||||
|
public boolean isStub() {
|
||||||
|
return this == FEC_STUB || this == NACK_STUB;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String overlayLabel() {
|
||||||
|
switch (this) {
|
||||||
|
case FEC_STUB:
|
||||||
|
return "FEC (stub)";
|
||||||
|
case NACK_STUB:
|
||||||
|
return "NACK (stub)";
|
||||||
|
case NONE:
|
||||||
|
default:
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-transport loss / damage counters (UDP datagrams, reassembly, app-layer drops).
|
||||||
|
* Thread-safe increments; copy for stats exchange.
|
||||||
|
*/
|
||||||
|
public final class TransportLossStats {
|
||||||
|
public long datagramsSent;
|
||||||
|
public long datagramsReceived;
|
||||||
|
public long datagramsInvalid;
|
||||||
|
public long fragmentsReceived;
|
||||||
|
public long fragmentsDuplicate;
|
||||||
|
public long reassemblyCompleted;
|
||||||
|
public long reassemblyIncomplete;
|
||||||
|
public long reassemblySuperseded;
|
||||||
|
public long wirePacketsDropped;
|
||||||
|
public long malformedPackets;
|
||||||
|
|
||||||
|
public int sendDroppedVideoP;
|
||||||
|
public int sendDroppedAudio;
|
||||||
|
public int sendQueueDepth;
|
||||||
|
|
||||||
|
public long recvVideoPackets;
|
||||||
|
public long recvAudioPackets;
|
||||||
|
public long recvVideoSkippedAwaitingKey;
|
||||||
|
public long recvVideoDecodeErrors;
|
||||||
|
public long recvAudioDecodeErrors;
|
||||||
|
public long recvAudioQueueDrops;
|
||||||
|
public long recvDamagedVideoFrames;
|
||||||
|
|
||||||
|
public StreamProtectionMode protectionMode = StreamProtectionMode.NONE;
|
||||||
|
public long fecPacketsStub;
|
||||||
|
public long nackRequestsStub;
|
||||||
|
public long nackRetransmitsStub;
|
||||||
|
|
||||||
|
public synchronized void addFrom(TransportLossStats other) {
|
||||||
|
if (other == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
datagramsSent += other.datagramsSent;
|
||||||
|
datagramsReceived += other.datagramsReceived;
|
||||||
|
datagramsInvalid += other.datagramsInvalid;
|
||||||
|
fragmentsReceived += other.fragmentsReceived;
|
||||||
|
fragmentsDuplicate += other.fragmentsDuplicate;
|
||||||
|
reassemblyCompleted += other.reassemblyCompleted;
|
||||||
|
reassemblyIncomplete += other.reassemblyIncomplete;
|
||||||
|
reassemblySuperseded += other.reassemblySuperseded;
|
||||||
|
wirePacketsDropped += other.wirePacketsDropped;
|
||||||
|
malformedPackets += other.malformedPackets;
|
||||||
|
sendDroppedVideoP += other.sendDroppedVideoP;
|
||||||
|
sendDroppedAudio += other.sendDroppedAudio;
|
||||||
|
recvVideoPackets += other.recvVideoPackets;
|
||||||
|
recvAudioPackets += other.recvAudioPackets;
|
||||||
|
recvVideoSkippedAwaitingKey += other.recvVideoSkippedAwaitingKey;
|
||||||
|
recvVideoDecodeErrors += other.recvVideoDecodeErrors;
|
||||||
|
recvAudioDecodeErrors += other.recvAudioDecodeErrors;
|
||||||
|
recvAudioQueueDrops += other.recvAudioQueueDrops;
|
||||||
|
recvDamagedVideoFrames += other.recvDamagedVideoFrames;
|
||||||
|
fecPacketsStub += other.fecPacketsStub;
|
||||||
|
nackRequestsStub += other.nackRequestsStub;
|
||||||
|
nackRetransmitsStub += other.nackRetransmitsStub;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized TransportLossStats copy() {
|
||||||
|
TransportLossStats c = new TransportLossStats();
|
||||||
|
c.datagramsSent = datagramsSent;
|
||||||
|
c.datagramsReceived = datagramsReceived;
|
||||||
|
c.datagramsInvalid = datagramsInvalid;
|
||||||
|
c.fragmentsReceived = fragmentsReceived;
|
||||||
|
c.fragmentsDuplicate = fragmentsDuplicate;
|
||||||
|
c.reassemblyCompleted = reassemblyCompleted;
|
||||||
|
c.reassemblyIncomplete = reassemblyIncomplete;
|
||||||
|
c.reassemblySuperseded = reassemblySuperseded;
|
||||||
|
c.wirePacketsDropped = wirePacketsDropped;
|
||||||
|
c.malformedPackets = malformedPackets;
|
||||||
|
c.sendDroppedVideoP = sendDroppedVideoP;
|
||||||
|
c.sendDroppedAudio = sendDroppedAudio;
|
||||||
|
c.sendQueueDepth = sendQueueDepth;
|
||||||
|
c.recvVideoPackets = recvVideoPackets;
|
||||||
|
c.recvAudioPackets = recvAudioPackets;
|
||||||
|
c.recvVideoSkippedAwaitingKey = recvVideoSkippedAwaitingKey;
|
||||||
|
c.recvVideoDecodeErrors = recvVideoDecodeErrors;
|
||||||
|
c.recvAudioDecodeErrors = recvAudioDecodeErrors;
|
||||||
|
c.recvAudioQueueDrops = recvAudioQueueDrops;
|
||||||
|
c.recvDamagedVideoFrames = recvDamagedVideoFrames;
|
||||||
|
c.protectionMode = protectionMode;
|
||||||
|
c.fecPacketsStub = fecPacketsStub;
|
||||||
|
c.nackRequestsStub = nackRequestsStub;
|
||||||
|
c.nackRetransmitsStub = nackRetransmitsStub;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimated UDP loss: incomplete reassembly + invalid, not double-counting fragments. */
|
||||||
|
public synchronized long estimatedUdpLossEvents() {
|
||||||
|
return reassemblyIncomplete + reassemblySuperseded + datagramsInvalid + malformedPackets;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
|
||||||
|
/** Copies {@link TransportLossStats} into {@link NetworkStatsSnapshot} for overlay / heartbeat. */
|
||||||
|
public final class TransportLossStatsBridge {
|
||||||
|
private TransportLossStatsBridge() {}
|
||||||
|
|
||||||
|
public static void applyTo(NetworkStatsSnapshot s, TransportLossStats loss) {
|
||||||
|
if (s == null || loss == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (loss) {
|
||||||
|
s.udpDatagramsRx = loss.datagramsReceived;
|
||||||
|
s.udpDatagramsTx = loss.datagramsSent;
|
||||||
|
s.udpDatagramsInvalid = loss.datagramsInvalid;
|
||||||
|
s.udpFragmentsRx = loss.fragmentsReceived;
|
||||||
|
s.udpFragmentsDuplicate = loss.fragmentsDuplicate;
|
||||||
|
s.udpReassemblyOk = loss.reassemblyCompleted;
|
||||||
|
s.udpReassemblyLost = loss.reassemblyIncomplete + loss.reassemblySuperseded;
|
||||||
|
s.udpWireDropped = loss.wirePacketsDropped;
|
||||||
|
s.udpMalformed = loss.malformedPackets;
|
||||||
|
s.droppedPFrames = Math.max(s.droppedPFrames, loss.sendDroppedVideoP);
|
||||||
|
s.droppedAudioFrames = loss.sendDroppedAudio;
|
||||||
|
s.encodeQueueDepth = Math.max(s.encodeQueueDepth, loss.sendQueueDepth);
|
||||||
|
s.recvVideoPackets = loss.recvVideoPackets;
|
||||||
|
s.recvAudioPackets = loss.recvAudioPackets;
|
||||||
|
s.recvVideoSkippedKey = loss.recvVideoSkippedAwaitingKey;
|
||||||
|
s.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
|
||||||
|
s.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
|
||||||
|
s.recvAudioQueueDrops = loss.recvAudioQueueDrops;
|
||||||
|
s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames;
|
||||||
|
s.protectionMode = loss.protectionMode.name();
|
||||||
|
s.fecStubPackets = loss.fecPacketsStub;
|
||||||
|
s.nackStubRequests = loss.nackRequestsStub;
|
||||||
|
long lossEvents = loss.estimatedUdpLossEvents();
|
||||||
|
if (loss.datagramsReceived > 0 && lossEvents > 0) {
|
||||||
|
s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.foxx.androidcast.network.transport;
|
||||||
|
|
||||||
|
/** Transports that expose {@link TransportLossStats}. */
|
||||||
|
public interface TransportStatsProvider {
|
||||||
|
TransportLossStats getLossStats();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.foxx.androidcast.network.webrtc;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Future WebRTC transport (libwebrtc or pion-backed). Not wired yet — use {@link #createStub()}.
|
||||||
|
*/
|
||||||
|
public interface WebRtcCastTransport extends CastTransport {
|
||||||
|
|
||||||
|
/** Signaling / ICE configuration for upcoming integration. */
|
||||||
|
interface SignalingConfig {
|
||||||
|
String getSignalingUrl();
|
||||||
|
|
||||||
|
String[] getIceServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach when integrating real PeerConnection / DataChannel. */
|
||||||
|
void attachPeer(WebRtcPeerAdapter peer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder until libwebrtc or pion transport is connected.
|
||||||
|
*/
|
||||||
|
static WebRtcCastTransport createStub() {
|
||||||
|
return new WebRtcCastTransportStub();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class WebRtcCastTransportStub implements WebRtcCastTransport {
|
||||||
|
@Override
|
||||||
|
public void attachPeer(WebRtcPeerAdapter peer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void listen(int port) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connect(String host, int port) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(byte type, byte[] payload) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("WebRTC transport is not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModeLabel() {
|
||||||
|
return "WebRTC (stub)";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.foxx.androidcast.network.webrtc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction over future {@code org.webrtc.PeerConnection} or pion peer APIs.
|
||||||
|
* Implementations will map cast frames to SCTP DataChannel or encoded tracks.
|
||||||
|
*/
|
||||||
|
public interface WebRtcPeerAdapter {
|
||||||
|
|
||||||
|
interface Observer {
|
||||||
|
void onIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
|
||||||
|
|
||||||
|
void onConnectionStateChanged(WebRtcConnectionState state);
|
||||||
|
|
||||||
|
void onDataChannelMessage(byte[] payload);
|
||||||
|
|
||||||
|
void onEncodedVideoFrame(long ptsUs, boolean keyFrame, byte[] data);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WebRtcConnectionState {
|
||||||
|
NEW, CONNECTING, CONNECTED, DISCONNECTED, FAILED, CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
void setObserver(Observer observer);
|
||||||
|
|
||||||
|
void createOffer();
|
||||||
|
|
||||||
|
void createAnswer(String remoteDescription);
|
||||||
|
|
||||||
|
void setRemoteDescription(String sdp, boolean isOffer);
|
||||||
|
|
||||||
|
void addIceCandidate(String sdpMid, int sdpMLineIndex, String candidate);
|
||||||
|
|
||||||
|
/** Send opaque cast protocol bytes on a labeled DataChannel. */
|
||||||
|
void sendDataChannel(byte[] payload);
|
||||||
|
|
||||||
|
void close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.foxx.androidcast.network.webrtc;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for WebRTC-backed cast transport (stub today; swap implementation for libwebrtc / pion).
|
||||||
|
*/
|
||||||
|
public final class WebRtcTransportFactory {
|
||||||
|
private WebRtcTransportFactory() {}
|
||||||
|
|
||||||
|
public static boolean isTransportId(String transport) {
|
||||||
|
return CastConfig.TRANSPORT_WEBRTC.equals(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CastTransport create() {
|
||||||
|
return WebRtcCastTransport.createStub();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import android.media.AudioManager;
|
|||||||
import android.media.AudioTrack;
|
import android.media.AudioTrack;
|
||||||
import android.media.MediaCodec;
|
import android.media.MediaCodec;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.HandlerThread;
|
import android.os.HandlerThread;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
@@ -36,6 +37,7 @@ public class AudioDecoder {
|
|||||||
public long pcmSilentBlocks;
|
public long pcmSilentBlocks;
|
||||||
public long pcmAudibleBlocks;
|
public long pcmAudibleBlocks;
|
||||||
public long decodeErrors;
|
public long decodeErrors;
|
||||||
|
public long queueDrops;
|
||||||
public float lastPeak;
|
public float lastPeak;
|
||||||
public float lastRms;
|
public float lastRms;
|
||||||
}
|
}
|
||||||
@@ -43,7 +45,8 @@ public class AudioDecoder {
|
|||||||
private final Context appContext;
|
private final Context appContext;
|
||||||
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
|
private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl");
|
||||||
private Handler worker;
|
private Handler worker;
|
||||||
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(128);
|
private final LinkedBlockingQueue<PendingFrame> inputQueue = new LinkedBlockingQueue<>(256);
|
||||||
|
private long playbackEpochUs = -1;
|
||||||
private final Stats stats = new Stats();
|
private final Stats stats = new Stats();
|
||||||
|
|
||||||
private MediaCodec codec;
|
private MediaCodec codec;
|
||||||
@@ -130,6 +133,7 @@ public class AudioDecoder {
|
|||||||
|
|
||||||
private void startDrainThread() {
|
private void startDrainThread() {
|
||||||
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
|
drainThread = new Thread(this::drainLoop, "AudioDecoderDrain");
|
||||||
|
drainThread.setPriority(Thread.MAX_PRIORITY - 2);
|
||||||
drainThread.start();
|
drainThread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +142,7 @@ public class AudioDecoder {
|
|||||||
? AudioFormat.CHANNEL_OUT_STEREO
|
? AudioFormat.CHANNEL_OUT_STEREO
|
||||||
: AudioFormat.CHANNEL_OUT_MONO;
|
: AudioFormat.CHANNEL_OUT_MONO;
|
||||||
int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
|
int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
|
||||||
int bufSize = Math.max(minBuf * 8, 65536);
|
int bufSize = Math.max(minBuf * 16, 131072);
|
||||||
track = new AudioTrack.Builder()
|
track = new AudioTrack.Builder()
|
||||||
.setAudioAttributes(new AudioAttributes.Builder()
|
.setAudioAttributes(new AudioAttributes.Builder()
|
||||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
@@ -164,8 +168,11 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
stats.framesQueued++;
|
stats.framesQueued++;
|
||||||
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||||
|
stats.queueDrops++;
|
||||||
inputQueue.poll();
|
inputQueue.poll();
|
||||||
inputQueue.offer(new PendingFrame(ptsUs, data.clone()));
|
if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) {
|
||||||
|
stats.queueDrops++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +248,8 @@ public class AudioDecoder {
|
|||||||
} else {
|
} else {
|
||||||
stats.pcmAudibleBlocks++;
|
stats.pcmAudibleBlocks++;
|
||||||
}
|
}
|
||||||
int written = writePcm(pcm);
|
pacePlayback(info.presentationTimeUs);
|
||||||
|
int written = writePcmBlocking(pcm);
|
||||||
if (written > 0) {
|
if (written > 0) {
|
||||||
stats.pcmBytesWritten += written;
|
stats.pcmBytesWritten += written;
|
||||||
if (renderCallback != null) {
|
if (renderCallback != null) {
|
||||||
@@ -253,16 +261,42 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int writePcm(byte[] pcm) {
|
private void pacePlayback(long ptsUs) {
|
||||||
|
long nowUs = System.nanoTime() / 1000L;
|
||||||
|
if (playbackEpochUs < 0) {
|
||||||
|
playbackEpochUs = nowUs - ptsUs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long dueUs = playbackEpochUs + ptsUs;
|
||||||
|
long aheadUs = dueUs - nowUs;
|
||||||
|
if (aheadUs > 120_000L) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(aheadUs / 1000L, (int) ((aheadUs % 1000L) * 1000L));
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int writePcmBlocking(byte[] pcm) {
|
||||||
int total = 0;
|
int total = 0;
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
while (offset < pcm.length) {
|
int idle = 0;
|
||||||
|
while (offset < pcm.length && idle < 80) {
|
||||||
int w = track.write(pcm, offset, pcm.length - offset);
|
int w = track.write(pcm, offset, pcm.length - offset);
|
||||||
if (w <= 0) {
|
if (w > 0) {
|
||||||
|
total += w;
|
||||||
|
offset += w;
|
||||||
|
idle = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
idle++;
|
||||||
|
try {
|
||||||
|
Thread.sleep(2);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
total += w;
|
|
||||||
offset += w;
|
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@@ -275,7 +309,11 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void release() {
|
/** Stops playback immediately and drops queued AAC (sender ended cast). */
|
||||||
|
public void flushAndStop() {
|
||||||
|
draining = false;
|
||||||
|
configured = false;
|
||||||
|
inputQueue.clear();
|
||||||
final Object lock = new Object();
|
final Object lock = new Object();
|
||||||
worker.post(() -> {
|
worker.post(() -> {
|
||||||
releaseOnWorker();
|
releaseOnWorker();
|
||||||
@@ -285,21 +323,26 @@ public class AudioDecoder {
|
|||||||
});
|
});
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
try {
|
try {
|
||||||
lock.wait(2000);
|
lock.wait(500);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void release() {
|
||||||
|
flushAndStop();
|
||||||
|
}
|
||||||
|
|
||||||
private void releaseOnWorker() {
|
private void releaseOnWorker() {
|
||||||
draining = false;
|
draining = false;
|
||||||
configured = false;
|
configured = false;
|
||||||
|
playbackEpochUs = -1;
|
||||||
inputQueue.clear();
|
inputQueue.clear();
|
||||||
if (drainThread != null) {
|
if (drainThread != null) {
|
||||||
drainThread.interrupt();
|
drainThread.interrupt();
|
||||||
try {
|
try {
|
||||||
drainThread.join(1500);
|
drainThread.join(400);
|
||||||
} catch (InterruptedException ignored) {
|
} catch (InterruptedException ignored) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
@@ -315,12 +358,26 @@ public class AudioDecoder {
|
|||||||
}
|
}
|
||||||
if (track != null) {
|
if (track != null) {
|
||||||
try {
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
track.pause();
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
track.flush();
|
||||||
|
}
|
||||||
track.stop();
|
track.stop();
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
track.release();
|
track.release();
|
||||||
track = null;
|
track = null;
|
||||||
}
|
}
|
||||||
|
abandonAudioFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void abandonAudioFocus() {
|
||||||
|
AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
|
||||||
|
if (am != null) {
|
||||||
|
am.abandonAudioFocus(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void releaseAndQuit() {
|
public void releaseAndQuit() {
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ import com.foxx.androidcast.R;
|
|||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
|
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
|
||||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -80,7 +84,10 @@ public class ReceiverCastService extends Service {
|
|||||||
private CastSettings remoteCastSettings;
|
private CastSettings remoteCastSettings;
|
||||||
private NetworkStatsSnapshot lastLocalStats;
|
private NetworkStatsSnapshot lastLocalStats;
|
||||||
private boolean streamIdle;
|
private boolean streamIdle;
|
||||||
|
private volatile boolean castEnded;
|
||||||
private long lastVideoFrameMs;
|
private long lastVideoFrameMs;
|
||||||
|
private final TransportLossStats transportLoss = new TransportLossStats();
|
||||||
|
private SessionStatsRecorder sessionStatsRecorder;
|
||||||
|
|
||||||
private final Runnable idleWatchdog = new Runnable() {
|
private final Runnable idleWatchdog = new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
@@ -136,6 +143,7 @@ public class ReceiverCastService extends Service {
|
|||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
CastNotifications.createChannels(this);
|
CastNotifications.createChannels(this);
|
||||||
|
SessionStatsStore.pruneOnAppStart(this);
|
||||||
mainHandler.post(idleWatchdog);
|
mainHandler.post(idleWatchdog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +183,11 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
private void restartListeningLocked(String pin) {
|
private void restartListeningLocked(String pin) {
|
||||||
stopSessionOnly();
|
stopSessionOnly();
|
||||||
|
castEnded = false;
|
||||||
resetStreamState();
|
resetStreamState();
|
||||||
|
pendingWidth = 0;
|
||||||
|
pendingHeight = 0;
|
||||||
|
playbackLaunched = false;
|
||||||
phase = Phase.LISTENING;
|
phase = Phase.LISTENING;
|
||||||
acquireWakeLock();
|
acquireWakeLock();
|
||||||
startForeground(CastNotifications.ID_RECEIVER,
|
startForeground(CastNotifications.ID_RECEIVER,
|
||||||
@@ -220,10 +232,10 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
|
||||||
if (reconfiguring) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (awaitingKeyframe && !keyFrame) {
|
if (awaitingKeyframe && !keyFrame) {
|
||||||
|
synchronized (transportLoss) {
|
||||||
|
transportLoss.recvVideoSkippedAwaitingKey++;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (keyFrame) {
|
if (keyFrame) {
|
||||||
@@ -237,7 +249,10 @@ public class ReceiverCastService extends Service {
|
|||||||
onStreamResumedAfterIdle();
|
onStreamResumedAfterIdle();
|
||||||
}
|
}
|
||||||
streamMetrics.onVideoPacket(keyFrame, frameBytes);
|
streamMetrics.onVideoPacket(keyFrame, frameBytes);
|
||||||
if (videoDecoder != null && !reconfiguring) {
|
synchronized (transportLoss) {
|
||||||
|
transportLoss.recvVideoPackets++;
|
||||||
|
}
|
||||||
|
if (videoDecoder != null) {
|
||||||
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
videoDecoder.queueFrame(ptsUs, keyFrame, data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -271,6 +286,9 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAudioFrame(long ptsUs, byte[] data) {
|
public void onAudioFrame(long ptsUs, byte[] data) {
|
||||||
|
if (castEnded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final int frameBytes = data != null ? data.length : 0;
|
final int frameBytes = data != null ? data.length : 0;
|
||||||
final boolean accept;
|
final boolean accept;
|
||||||
synchronized (ReceiverCastService.this) {
|
synchronized (ReceiverCastService.this) {
|
||||||
@@ -279,7 +297,17 @@ public class ReceiverCastService extends Service {
|
|||||||
if (accept && audioDecoder != null) {
|
if (accept && audioDecoder != null) {
|
||||||
audioDecoder.queueFrame(ptsUs, data);
|
audioDecoder.queueFrame(ptsUs, data);
|
||||||
}
|
}
|
||||||
mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes));
|
mainHandler.post(() -> {
|
||||||
|
streamMetrics.onAudioPacket(frameBytes);
|
||||||
|
synchronized (transportLoss) {
|
||||||
|
transportLoss.recvAudioPackets++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCastEnded() {
|
||||||
|
mainHandler.postAtFrontOfQueue(ReceiverCastService.this::stopAudioPlaybackImmediate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -304,6 +332,13 @@ public class ReceiverCastService extends Service {
|
|||||||
|
|
||||||
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
|
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
|
||||||
phase = Phase.CONNECTED;
|
phase = Phase.CONNECTED;
|
||||||
|
castEnded = false;
|
||||||
|
if (sessionStatsRecorder == null) {
|
||||||
|
sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport());
|
||||||
|
sessionStatsRecorder.setClientCount(1);
|
||||||
|
}
|
||||||
|
sessionStatsRecorder.mergeSettings(remoteSettings);
|
||||||
|
sessionStatsRecorder.setCodecs(videoMime, "AAC");
|
||||||
negotiatedVideoMime = videoMime;
|
negotiatedVideoMime = videoMime;
|
||||||
remoteCastSettings = remoteSettings;
|
remoteCastSettings = remoteSettings;
|
||||||
streamIdle = false;
|
streamIdle = false;
|
||||||
@@ -332,25 +367,32 @@ public class ReceiverCastService extends Service {
|
|||||||
}, "AudioConsent").start();
|
}, "AudioConsent").start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void stopAudioPlaybackImmediate() {
|
||||||
|
castEnded = true;
|
||||||
|
if (audioDecoder != null) {
|
||||||
|
audioDecoder.flushAndStop();
|
||||||
|
audioDecoder = new AudioDecoder(this);
|
||||||
|
audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
|
||||||
|
pcmBytes, levels.peak, levels.rms, levels.silent));
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Audio playback stopped");
|
||||||
|
}
|
||||||
|
|
||||||
private void onSenderDisconnected() {
|
private void onSenderDisconnected() {
|
||||||
|
stopAudioPlaybackImmediate();
|
||||||
phase = Phase.LISTENING;
|
phase = Phase.LISTENING;
|
||||||
audioAccepted = null;
|
audioAccepted = null;
|
||||||
remoteCastSettings = null;
|
remoteCastSettings = null;
|
||||||
streamIdle = false;
|
streamIdle = false;
|
||||||
lastVideoFrameMs = 0;
|
lastVideoFrameMs = 0;
|
||||||
negotiatedVideoMime = null;
|
negotiatedVideoMime = null;
|
||||||
enterAwaitingMode(R.string.playback_stream_ended, true);
|
pendingWidth = 0;
|
||||||
updateStatus(getString(R.string.waiting_sender));
|
pendingHeight = 0;
|
||||||
closePlaybackUi();
|
hasPendingConfig = false;
|
||||||
openReceiverSetupUi();
|
resetDecoderState();
|
||||||
Log.i(TAG, "Sender disconnected");
|
enterAwaitingMode(R.string.playback_listening, true);
|
||||||
}
|
updateStatus(getString(R.string.receiver_ready, activePin, settings.getTransport().toUpperCase()));
|
||||||
|
Log.i(TAG, "Sender disconnected — listening again");
|
||||||
private void openReceiverSetupUi() {
|
|
||||||
Intent intent = new Intent(this, ReceiverActivity.class);
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkStreamIdle() {
|
private void checkStreamIdle() {
|
||||||
@@ -360,6 +402,9 @@ public class ReceiverCastService extends Service {
|
|||||||
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
|
long idleMs = System.currentTimeMillis() - lastVideoFrameMs;
|
||||||
if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) {
|
if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) {
|
||||||
Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting");
|
Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting");
|
||||||
|
if (sessionStatsRecorder != null) {
|
||||||
|
sessionStatsRecorder.noteIdleTimeout();
|
||||||
|
}
|
||||||
enterAwaitingMode(R.string.playback_awaiting_stream, false);
|
enterAwaitingMode(R.string.playback_awaiting_stream, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,6 +504,9 @@ public class ReceiverCastService extends Service {
|
|||||||
decoderActive = false;
|
decoderActive = false;
|
||||||
notifyPlaybackDimensions(pendingWidth, pendingHeight);
|
notifyPlaybackDimensions(pendingWidth, pendingHeight);
|
||||||
scheduleDecoderConfigureRetries();
|
scheduleDecoderConfigureRetries();
|
||||||
|
if (sessionStatsRecorder != null) {
|
||||||
|
sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
|
||||||
|
}
|
||||||
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
|
Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,9 +552,11 @@ public class ReceiverCastService extends Service {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
discovery = new DiscoveryManager(this);
|
discovery = new DiscoveryManager(this);
|
||||||
discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT,
|
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||||
|
int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
|
||||||
|
discovery.startAnnouncing(android.os.Build.MODEL, announcePort,
|
||||||
settings.getTransport(), true);
|
settings.getTransport(), true);
|
||||||
Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT);
|
Log.i(TAG, "Discovery announcing on port " + announcePort);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopSessionOnly() {
|
private void stopSessionOnly() {
|
||||||
@@ -545,6 +595,7 @@ public class ReceiverCastService extends Service {
|
|||||||
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
|
||||||
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
|
Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
|
||||||
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
|
+ " surface=" + (attachedSurface != null && attachedSurface.isValid()));
|
||||||
|
reconfiguring = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
|
if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
|
||||||
@@ -569,16 +620,24 @@ public class ReceiverCastService extends Service {
|
|||||||
public void onFrameRendered() {
|
public void onFrameRendered() {
|
||||||
ReceiverCastService.this.onFrameRendered();
|
ReceiverCastService.this.onFrameRendered();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOutputSizeChanged(int width, int height) {
|
||||||
|
ReceiverCastService.this.onDecoderOutputSize(width, height);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
decoderActive = true;
|
decoderActive = true;
|
||||||
reconfiguring = false;
|
reconfiguring = false;
|
||||||
decoderWidth = pendingWidth;
|
decoderWidth = pendingWidth;
|
||||||
decoderHeight = pendingHeight;
|
decoderHeight = pendingHeight;
|
||||||
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
|
updateStatus(getString(R.string.playback_awaiting_stream));
|
||||||
broadcastStreamStarted(pendingWidth, pendingHeight);
|
broadcastStreamStarted(pendingWidth, pendingHeight);
|
||||||
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
|
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
decoderActive = false;
|
decoderActive = false;
|
||||||
|
synchronized (transportLoss) {
|
||||||
|
transportLoss.recvVideoDecodeErrors++;
|
||||||
|
}
|
||||||
Log.e(TAG, "Video configure failed", e);
|
Log.e(TAG, "Video configure failed", e);
|
||||||
updateStatus("Video: " + e.getMessage());
|
updateStatus("Video: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -589,6 +648,9 @@ public class ReceiverCastService extends Service {
|
|||||||
streamMetrics.onRenderedFrame();
|
streamMetrics.onRenderedFrame();
|
||||||
Log.i(TAG, "First frame on screen");
|
Log.i(TAG, "First frame on screen");
|
||||||
streamIdle = false;
|
streamIdle = false;
|
||||||
|
if (pendingWidth > 0 && pendingHeight > 0) {
|
||||||
|
updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
|
||||||
|
}
|
||||||
broadcastVideoRendering();
|
broadcastVideoRendering();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -597,8 +659,27 @@ public class ReceiverCastService extends Service {
|
|||||||
mainHandler.post(() -> streamMetrics.onRenderedFrame());
|
mainHandler.post(() -> streamMetrics.onRenderedFrame());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void onDecoderOutputSize(int width, int height) {
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingWidth = width;
|
||||||
|
pendingHeight = height;
|
||||||
|
decoderWidth = width;
|
||||||
|
decoderHeight = height;
|
||||||
|
notifyPlaybackDimensions(width, height);
|
||||||
|
Log.i(TAG, "Decoder output size " + width + "x" + height);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
|
private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
|
||||||
s.encodeFps = streamMetrics.getDecodeFps();
|
s.encodeFps = streamMetrics.getDecodeFps();
|
||||||
|
TransportLossStats snap = buildReceiverLossSnapshot();
|
||||||
|
TransportLossStatsBridge.applyTo(s, snap);
|
||||||
|
if (sessionStatsRecorder != null) {
|
||||||
|
sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer());
|
||||||
|
}
|
||||||
NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
|
NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
|
||||||
CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings;
|
CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings;
|
||||||
com.foxx.androidcast.CastTuningEngine.EffectiveParams eff =
|
com.foxx.androidcast.CastTuningEngine.EffectiveParams eff =
|
||||||
@@ -634,6 +715,34 @@ public class ReceiverCastService extends Service {
|
|||||||
PlaybackViewState.getZoomPercent());
|
PlaybackViewState.getZoomPercent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TransportLossStats buildReceiverLossSnapshot() {
|
||||||
|
TransportLossStats snap = session != null
|
||||||
|
? session.getTransportLossStats() : new TransportLossStats();
|
||||||
|
synchronized (transportLoss) {
|
||||||
|
snap.recvVideoSkippedAwaitingKey = transportLoss.recvVideoSkippedAwaitingKey;
|
||||||
|
snap.recvVideoPackets = transportLoss.recvVideoPackets;
|
||||||
|
snap.recvAudioPackets = transportLoss.recvAudioPackets;
|
||||||
|
snap.recvVideoDecodeErrors = transportLoss.recvVideoDecodeErrors;
|
||||||
|
}
|
||||||
|
if (audioDecoder != null) {
|
||||||
|
AudioDecoder.Stats ast = audioDecoder.getStats();
|
||||||
|
snap.recvAudioDecodeErrors = ast.decodeErrors;
|
||||||
|
snap.recvAudioQueueDrops = ast.queueDrops;
|
||||||
|
}
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishSessionStatsAsync() {
|
||||||
|
final SessionStatsRecorder recorder = sessionStatsRecorder;
|
||||||
|
sessionStatsRecorder = null;
|
||||||
|
if (recorder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recorder.setResolution(pendingWidth, pendingHeight);
|
||||||
|
recorder.mergeLoss(buildReceiverLossSnapshot());
|
||||||
|
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
|
||||||
|
}
|
||||||
|
|
||||||
private void attachSurface(Surface surface) {
|
private void attachSurface(Surface surface) {
|
||||||
if (surface == null || !surface.isValid()) {
|
if (surface == null || !surface.isValid()) {
|
||||||
Log.w(TAG, "attachSurface: invalid surface");
|
Log.w(TAG, "attachSurface: invalid surface");
|
||||||
@@ -732,6 +841,7 @@ public class ReceiverCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void releaseAll() {
|
private void releaseAll() {
|
||||||
|
finishSessionStatsAsync();
|
||||||
phase = Phase.IDLE;
|
phase = Phase.IDLE;
|
||||||
resetStreamState();
|
resetStreamState();
|
||||||
playbackLaunched = false;
|
playbackLaunched = false;
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import android.os.Handler;
|
|||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
import android.view.SurfaceHolder;
|
import android.graphics.SurfaceTexture;
|
||||||
import android.view.SurfaceView;
|
import android.view.Surface;
|
||||||
|
import android.view.TextureView;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
import android.animation.AnimatorSet;
|
import android.animation.AnimatorSet;
|
||||||
@@ -27,6 +28,7 @@ import com.foxx.androidcast.ICastReceiverService;
|
|||||||
import com.foxx.androidcast.ICastStatusCallback;
|
import com.foxx.androidcast.ICastStatusCallback;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
|
import com.foxx.androidcast.ui.AspectRatioFrameLayout;
|
||||||
|
import com.foxx.androidcast.ui.PlaybackTextureTransform;
|
||||||
import com.foxx.androidcast.ui.ZoomPanContainer;
|
import com.foxx.androidcast.ui.ZoomPanContainer;
|
||||||
|
|
||||||
import androidx.core.text.HtmlCompat;
|
import androidx.core.text.HtmlCompat;
|
||||||
@@ -35,7 +37,7 @@ import androidx.core.text.HtmlCompat;
|
|||||||
* Fullscreen display: dancing-robot overlay while listening / waiting;
|
* Fullscreen display: dancing-robot overlay while listening / waiting;
|
||||||
* video surface shown only after the first decoded frame.
|
* video surface shown only after the first decoded frame.
|
||||||
*/
|
*/
|
||||||
public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback {
|
public class ReceiverPlaybackActivity extends AppCompatActivity {
|
||||||
public static final String EXTRA_WIDTH = "video_width";
|
public static final String EXTRA_WIDTH = "video_width";
|
||||||
public static final String EXTRA_HEIGHT = "video_height";
|
public static final String EXTRA_HEIGHT = "video_height";
|
||||||
public static final String EXTRA_FINISH = "finish_playback";
|
public static final String EXTRA_FINISH = "finish_playback";
|
||||||
@@ -43,13 +45,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message";
|
public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message";
|
||||||
|
|
||||||
private AspectRatioFrameLayout aspectContainer;
|
private AspectRatioFrameLayout aspectContainer;
|
||||||
private SurfaceView surfaceView;
|
private TextureView textureView;
|
||||||
private View awaitingOverlay;
|
private View awaitingOverlay;
|
||||||
private TextView diagnosticsText;
|
private TextView diagnosticsText;
|
||||||
private ImageView robotView;
|
private ImageView robotView;
|
||||||
private TextView awaitingMessage;
|
private TextView awaitingMessage;
|
||||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
private SurfaceHolder surfaceHolder;
|
private SurfaceTexture surfaceTexture;
|
||||||
private int videoWidth;
|
private int videoWidth;
|
||||||
private int videoHeight;
|
private int videoHeight;
|
||||||
private boolean streamRendering;
|
private boolean streamRendering;
|
||||||
@@ -78,6 +80,11 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
streamRendering = true;
|
streamRendering = true;
|
||||||
hideAwaitingOverlay();
|
hideAwaitingOverlay();
|
||||||
|
ZoomPanContainer zoom = findViewById(R.id.zoom_container);
|
||||||
|
if (zoom != null) {
|
||||||
|
zoom.resetTransform();
|
||||||
|
}
|
||||||
|
applyTextureFitCenter();
|
||||||
updateDiagnosticsVisibility();
|
updateDiagnosticsVisibility();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -138,15 +145,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
|
|
||||||
ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container);
|
ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container);
|
||||||
aspectContainer = findViewById(R.id.aspect_container);
|
aspectContainer = findViewById(R.id.aspect_container);
|
||||||
surfaceView = findViewById(R.id.surface_playback);
|
textureView = findViewById(R.id.surface_playback);
|
||||||
zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent);
|
zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent);
|
||||||
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
awaitingOverlay = findViewById(R.id.overlay_awaiting);
|
||||||
diagnosticsText = findViewById(R.id.text_diagnostics);
|
diagnosticsText = findViewById(R.id.text_diagnostics);
|
||||||
robotView = findViewById(R.id.image_robot);
|
robotView = findViewById(R.id.image_robot);
|
||||||
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
||||||
surfaceHolder = surfaceView.getHolder();
|
textureView.setSurfaceTextureListener(surfaceListener);
|
||||||
surfaceHolder.addCallback(this);
|
|
||||||
surfaceView.setZOrderMediaOverlay(false);
|
|
||||||
applyAspectRatio();
|
applyAspectRatio();
|
||||||
|
|
||||||
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
|
||||||
@@ -319,12 +324,24 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
videoWidth = width;
|
videoWidth = width;
|
||||||
videoHeight = height;
|
videoHeight = height;
|
||||||
applyAspectRatio();
|
applyAspectRatio();
|
||||||
if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) {
|
applyTextureFitCenter();
|
||||||
surfaceHolder.setFixedSize(videoWidth, videoHeight);
|
if (surfaceTexture != null) {
|
||||||
|
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||||
attachSurfaceIfReady();
|
attachSurfaceIfReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applyTextureFitCenter() {
|
||||||
|
if (textureView == null || videoWidth <= 0 || videoHeight <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (textureView.getWidth() == 0 || textureView.getHeight() == 0) {
|
||||||
|
textureView.post(this::applyTextureFitCenter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlaybackTextureTransform.applyFitCenter(textureView, videoWidth, videoHeight);
|
||||||
|
}
|
||||||
|
|
||||||
private void applyFullscreen() {
|
private void applyFullscreen() {
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
getWindow().getDecorView().setSystemUiVisibility(
|
getWindow().getDecorView().setSystemUiVisibility(
|
||||||
@@ -340,42 +357,49 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void applyAspectRatio() {
|
private void applyAspectRatio() {
|
||||||
if (videoWidth > 0 && videoHeight > 0) {
|
// Full-screen TextureView; letterboxing via transform only (avoids double-scaling).
|
||||||
aspectContainer.setAspectRatio(videoWidth, videoHeight);
|
aspectContainer.setAspectRatio(0, 0);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private final TextureView.SurfaceTextureListener surfaceListener =
|
||||||
public void surfaceCreated(SurfaceHolder holder) {
|
new TextureView.SurfaceTextureListener() {
|
||||||
if (videoWidth > 0 && videoHeight > 0) {
|
@Override
|
||||||
holder.setFixedSize(videoWidth, videoHeight);
|
public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) {
|
||||||
}
|
surfaceTexture = texture;
|
||||||
surfaceSentToService = false;
|
if (videoWidth > 0 && videoHeight > 0) {
|
||||||
attachSurfaceIfReady();
|
texture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||||
}
|
}
|
||||||
|
surfaceSentToService = false;
|
||||||
|
attachSurfaceIfReady();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) {
|
||||||
if (videoWidth > 0 && videoHeight > 0) {
|
if (videoWidth > 0 && videoHeight > 0) {
|
||||||
holder.setFixedSize(videoWidth, videoHeight);
|
texture.setDefaultBufferSize(videoWidth, videoHeight);
|
||||||
}
|
}
|
||||||
}
|
applyTextureFitCenter();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void surfaceDestroyed(SurfaceHolder holder) {
|
public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) {
|
||||||
surfaceSentToService = false;
|
surfaceSentToService = false;
|
||||||
detachSurface();
|
detachSurface();
|
||||||
}
|
surfaceTexture = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSurfaceTextureUpdated(SurfaceTexture texture) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private void attachSurfaceIfReady() {
|
private void attachSurfaceIfReady() {
|
||||||
if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) {
|
if (surfaceSentToService || !bound || receiverService == null || surfaceTexture == null) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!surfaceHolder.getSurface().isValid()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
receiverService.attachVideoSurface(surfaceHolder.getSurface());
|
receiverService.attachVideoSurface(new Surface(surfaceTexture));
|
||||||
surfaceSentToService = true;
|
surfaceSentToService = true;
|
||||||
} catch (RemoteException ignored) {
|
} catch (RemoteException ignored) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import com.foxx.androidcast.network.CastCodecFlags;
|
|||||||
import com.foxx.androidcast.network.CastFramingContext;
|
import com.foxx.androidcast.network.CastFramingContext;
|
||||||
import com.foxx.androidcast.network.CastProtocol;
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
import com.foxx.androidcast.network.CastSession;
|
import com.foxx.androidcast.network.CastSession;
|
||||||
|
import com.foxx.androidcast.network.CastSessionGate;
|
||||||
import com.foxx.androidcast.network.CastTransport;
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
import com.foxx.androidcast.network.CastTransportFactory;
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
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.NetworkStatsSnapshot;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
@@ -41,6 +43,9 @@ public class ReceiverSession {
|
|||||||
|
|
||||||
void onDisconnected();
|
void onDisconnected();
|
||||||
|
|
||||||
|
/** Sender sent {@link CastProtocol#MSG_GOODBYE} — stop audio/video playback immediately. */
|
||||||
|
void onCastEnded();
|
||||||
|
|
||||||
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
|
||||||
void onListening();
|
void onListening();
|
||||||
|
|
||||||
@@ -58,6 +63,7 @@ public class ReceiverSession {
|
|||||||
|
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
private CastTransport listenTransport;
|
private CastTransport listenTransport;
|
||||||
|
private final CastSessionGate inboundSessionGate = new CastSessionGate();
|
||||||
private NetworkFeedbackManager networkFeedback;
|
private NetworkFeedbackManager networkFeedback;
|
||||||
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
||||||
|
|
||||||
@@ -94,6 +100,14 @@ public class ReceiverSession {
|
|||||||
listenTransport.close();
|
listenTransport.close();
|
||||||
listenTransport = null;
|
listenTransport = null;
|
||||||
}
|
}
|
||||||
|
inboundSessionGate.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransportLossStats getTransportLossStats() {
|
||||||
|
if (listenTransport != null) {
|
||||||
|
return listenTransport.getLossStats();
|
||||||
|
}
|
||||||
|
return new TransportLossStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendAudioConsent(boolean accept) {
|
public void sendAudioConsent(boolean accept) {
|
||||||
@@ -111,17 +125,21 @@ public class ReceiverSession {
|
|||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
try {
|
try {
|
||||||
if (listenTransport == null) {
|
if (listenTransport == null) {
|
||||||
|
com.foxx.androidcast.network.CastTransportFactory.init(appContext);
|
||||||
listenTransport = CastTransportFactory.create(localSettings);
|
listenTransport = CastTransportFactory.create(localSettings);
|
||||||
|
inboundSessionGate.reset();
|
||||||
|
listenTransport.setInboundSessionGate(inboundSessionGate);
|
||||||
listenTransport.setFramingContext(new CastFramingContext(
|
listenTransport.setFramingContext(new CastFramingContext(
|
||||||
0,
|
0,
|
||||||
DeviceInfo.getPlaceholderId(appContext),
|
DeviceInfo.getPlaceholderId(appContext),
|
||||||
CastCodecFlags.localDecoderVideoMask(),
|
CastCodecFlags.localDecoderVideoMask(),
|
||||||
CastCodecFlags.localAudioMask()));
|
CastCodecFlags.localAudioMask()));
|
||||||
session = new CastSession(listenTransport);
|
session = new CastSession(listenTransport);
|
||||||
session.prepareListen(CastConfig.CAST_PORT);
|
int listenPort = CastConfig.portForTransport(localSettings.getTransport());
|
||||||
|
session.prepareListen(listenPort);
|
||||||
listener.onListening();
|
listener.onListening();
|
||||||
postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
|
postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
|
||||||
+ CastConfig.CAST_PORT);
|
+ listenPort);
|
||||||
} else if (session == null) {
|
} else if (session == null) {
|
||||||
session = new CastSession(listenTransport);
|
session = new CastSession(listenTransport);
|
||||||
}
|
}
|
||||||
@@ -158,7 +176,13 @@ public class ReceiverSession {
|
|||||||
} finally {
|
} finally {
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
session.releaseConnection();
|
session.releaseConnection();
|
||||||
|
session = null;
|
||||||
}
|
}
|
||||||
|
if (listenTransport != null) {
|
||||||
|
listenTransport.close();
|
||||||
|
listenTransport = null;
|
||||||
|
}
|
||||||
|
inboundSessionGate.reset();
|
||||||
networkFeedback = null;
|
networkFeedback = null;
|
||||||
listener.onDisconnected();
|
listener.onDisconnected();
|
||||||
}
|
}
|
||||||
@@ -214,6 +238,7 @@ public class ReceiverSession {
|
|||||||
break;
|
break;
|
||||||
case CastProtocol.MSG_GOODBYE:
|
case CastProtocol.MSG_GOODBYE:
|
||||||
postStatus("Sender stopped casting");
|
postStatus("Sender stopped casting");
|
||||||
|
listener.onCastEnded();
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public class VideoDecoder {
|
|||||||
|
|
||||||
/** Called for each frame after the first. */
|
/** Called for each frame after the first. */
|
||||||
default void onFrameRendered() {}
|
default void onFrameRendered() {}
|
||||||
|
|
||||||
|
/** Decoder output size (may differ from stream config crop). */
|
||||||
|
default void onOutputSizeChanged(int width, int height) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final Object lock = new Object();
|
private final Object lock = new Object();
|
||||||
@@ -66,7 +69,7 @@ public class VideoDecoder {
|
|||||||
if (released || data == null || data.length == 0) {
|
if (released || data == null || data.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data;
|
byte[] annexB = needsAnnexB(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;
|
||||||
@@ -143,7 +146,9 @@ public class VideoDecoder {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||||
Log.i(TAG, "Output format: " + c.getOutputFormat());
|
MediaFormat fmt = c.getOutputFormat();
|
||||||
|
Log.i(TAG, "Output format: " + fmt);
|
||||||
|
notifyOutputSizeLocked(fmt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (outIndex < 0) {
|
if (outIndex < 0) {
|
||||||
@@ -161,6 +166,23 @@ public class VideoDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void notifyOutputSizeLocked(MediaFormat format) {
|
||||||
|
if (listener == null || format == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int w = format.getInteger(MediaFormat.KEY_WIDTH);
|
||||||
|
int h = format.getInteger(MediaFormat.KEY_HEIGHT);
|
||||||
|
if (format.containsKey("crop-right") && format.containsKey("crop-left")) {
|
||||||
|
w = format.getInteger("crop-right") + 1 - format.getInteger("crop-left");
|
||||||
|
}
|
||||||
|
if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) {
|
||||||
|
h = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top");
|
||||||
|
}
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
listener.onOutputSizeChanged(w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void notifyFirstFrameLocked() {
|
private void notifyFirstFrameLocked() {
|
||||||
if (listener == null) {
|
if (listener == null) {
|
||||||
return;
|
return;
|
||||||
@@ -175,6 +197,10 @@ public class VideoDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean needsAnnexB(String mime) {
|
||||||
|
return CodecNegotiator.isH264(mime) || CodecNegotiator.isHevc(mime);
|
||||||
|
}
|
||||||
|
|
||||||
private static final class PendingFrame {
|
private static final class PendingFrame {
|
||||||
final long ptsUs;
|
final long ptsUs;
|
||||||
final boolean keyFrame;
|
final boolean keyFrame;
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ public class AudioCapture {
|
|||||||
Log.w(TAG, "Audio capture requires API 29+");
|
Log.w(TAG, "Audio capture requires API 29+");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (projection == null) {
|
||||||
|
startMicOnly(callback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!startPlayback(projection)) {
|
if (!startPlayback(projection)) {
|
||||||
Log.e(TAG, "Playback capture unavailable");
|
Log.e(TAG, "Playback capture unavailable");
|
||||||
return;
|
return;
|
||||||
@@ -72,6 +76,59 @@ public class AudioCapture {
|
|||||||
thread.start();
|
thread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void startMicOnly(Callback callback) {
|
||||||
|
micRecord = canUseMic() ? buildMicRecord() : null;
|
||||||
|
if (micRecord == null || micRecord.getState() != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
micRecord = null;
|
||||||
|
Log.w(TAG, "Mic-only capture unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
micRecord.startRecording();
|
||||||
|
captureMode = "mic";
|
||||||
|
if (callback != null) {
|
||||||
|
callback.onCaptureModeChanged(captureMode);
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Mic-only capture for calibration VU");
|
||||||
|
thread = new Thread(() -> micOnlyLoop(callback), "AudioCaptureMic");
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void micOnlyLoop(Callback callback) {
|
||||||
|
int frameBytes = pcmFrameBytes();
|
||||||
|
byte[] micBuf = new byte[frameBytes];
|
||||||
|
byte[] copy = new byte[frameBytes];
|
||||||
|
long frameIndex = 0;
|
||||||
|
while (running) {
|
||||||
|
if (!readFully(micRecord, micBuf, frameBytes)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
System.arraycopy(micBuf, 0, copy, 0, frameBytes);
|
||||||
|
long ptsUs = frameIndex * 20_000L;
|
||||||
|
frameIndex++;
|
||||||
|
if (callback != null) {
|
||||||
|
callback.onPcm(copy, frameBytes, ptsUs,
|
||||||
|
com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int pcmFrameBytes() {
|
||||||
|
return CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean readFully(AudioRecord record, byte[] buf, int length) {
|
||||||
|
int off = 0;
|
||||||
|
while (off < length && record != null) {
|
||||||
|
int n = record.read(buf, off, length - off);
|
||||||
|
if (n <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
off += n;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private boolean startPlayback(MediaProjection projection) {
|
private boolean startPlayback(MediaProjection projection) {
|
||||||
try {
|
try {
|
||||||
playbackRecord = buildPlaybackRecord(projection);
|
playbackRecord = buildPlaybackRecord(projection);
|
||||||
@@ -138,40 +195,32 @@ public class AudioCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void mixLoop(Callback callback) {
|
private void mixLoop(Callback callback) {
|
||||||
byte[] playBuf = new byte[4096];
|
int frameBytes = pcmFrameBytes();
|
||||||
byte[] micBuf = new byte[4096];
|
byte[] playBuf = new byte[frameBytes];
|
||||||
long startNs = System.nanoTime();
|
byte[] micBuf = new byte[frameBytes];
|
||||||
|
byte[] mixOut = new byte[frameBytes];
|
||||||
|
long frameIndex = 0;
|
||||||
while (running) {
|
while (running) {
|
||||||
int pRead = playbackRecord.read(playBuf, 0, playBuf.length);
|
if (!readFully(playbackRecord, playBuf, frameBytes)) {
|
||||||
int mRead = micRecord != null ? micRecord.read(micBuf, 0, micBuf.length) : 0;
|
|
||||||
if (pRead < 0 && mRead < 0) {
|
|
||||||
Log.w(TAG, "Audio read error");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byte[] out;
|
int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0;
|
||||||
int outLen;
|
System.arraycopy(playBuf, 0, mixOut, 0, frameBytes);
|
||||||
if (pRead > 0) {
|
if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) {
|
||||||
out = playBuf;
|
for (int i = 0; i < frameBytes; i++) {
|
||||||
outLen = pRead;
|
mixOut[i] = (byte) Math.max(mixOut[i] & 0xff, micBuf[i] & 0xff);
|
||||||
String mode = hasAudibleSamples(playBuf, pRead) ? "playback" : "playback-quiet";
|
|
||||||
if (!mode.equals(captureMode)) {
|
|
||||||
captureMode = mode;
|
|
||||||
notifyMode(callback, captureMode);
|
|
||||||
}
|
}
|
||||||
} else if (mRead > 0 && micRecord != null) {
|
|
||||||
out = micBuf;
|
|
||||||
outLen = mRead;
|
|
||||||
if (!"mic".equals(captureMode)) {
|
|
||||||
captureMode = "mic";
|
|
||||||
notifyMode(callback, captureMode);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
long ptsUs = (System.nanoTime() - startNs) / 1000;
|
String mode = hasAudibleSamples(playBuf, frameBytes) ? "playback" : "playback-quiet";
|
||||||
|
if (!mode.equals(captureMode)) {
|
||||||
|
captureMode = mode;
|
||||||
|
notifyMode(callback, captureMode);
|
||||||
|
}
|
||||||
|
long ptsUs = frameIndex * 20_000L;
|
||||||
|
frameIndex++;
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen);
|
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes);
|
||||||
callback.onPcm(out, outLen, ptsUs, levels);
|
callback.onPcm(mixOut, frameBytes, ptsUs, levels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ public class AudioEncoder {
|
|||||||
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
|
MediaCodec c = codec;
|
||||||
|
codec = null;
|
||||||
if (drainThread != null) {
|
if (drainThread != null) {
|
||||||
try {
|
try {
|
||||||
drainThread.join(1000);
|
drainThread.join(1000);
|
||||||
@@ -92,13 +94,15 @@ public class AudioEncoder {
|
|||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (codec != null) {
|
if (c != null) {
|
||||||
try {
|
try {
|
||||||
codec.stop();
|
c.stop();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
c.release();
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
codec.release();
|
|
||||||
codec = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,25 +11,32 @@ import java.util.concurrent.LinkedBlockingQueue;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/** Background TCP/UDP send queue so encoding is not blocked by network I/O. */
|
/** Background send queue: audio has its own lane so video backlog cannot starve it. */
|
||||||
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_VIDEO_QUEUE = 40;
|
||||||
|
private static final int MAX_AUDIO_QUEUE = 96;
|
||||||
|
|
||||||
private final LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
|
private final LinkedBlockingQueue<Outgoing> videoQueue = new LinkedBlockingQueue<>(MAX_VIDEO_QUEUE);
|
||||||
|
private final LinkedBlockingQueue<Outgoing> audioQueue = new LinkedBlockingQueue<>(MAX_AUDIO_QUEUE);
|
||||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
private Thread thread;
|
private Thread thread;
|
||||||
private CastSession session;
|
private CastSession session;
|
||||||
private Runnable onSendFailed;
|
private Runnable onSendFailed;
|
||||||
private PassThroughNetworkControlPlane statsPlane;
|
private PassThroughNetworkControlPlane statsPlane;
|
||||||
private volatile int droppedPFrames;
|
private volatile int droppedPFrames;
|
||||||
|
private volatile int droppedAudioFrames;
|
||||||
|
|
||||||
public int getDroppedPFrames() {
|
public int getDroppedPFrames() {
|
||||||
return droppedPFrames;
|
return droppedPFrames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getDroppedAudioFrames() {
|
||||||
|
return droppedAudioFrames;
|
||||||
|
}
|
||||||
|
|
||||||
public int getQueueDepth() {
|
public int getQueueDepth() {
|
||||||
return queue.size();
|
return videoQueue.size() + audioQueue.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start(CastSession session, Runnable onSendFailed) {
|
public void start(CastSession session, Runnable onSendFailed) {
|
||||||
@@ -44,21 +51,23 @@ public final class CastSendPump {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
thread = new Thread(this::run, "CastSendPump");
|
thread = new Thread(this::run, "CastSendPump");
|
||||||
|
thread.setPriority(Thread.NORM_PRIORITY + 1);
|
||||||
thread.start();
|
thread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running.set(false);
|
running.set(false);
|
||||||
|
videoQueue.clear();
|
||||||
|
audioQueue.clear();
|
||||||
if (thread != null) {
|
if (thread != null) {
|
||||||
thread.interrupt();
|
thread.interrupt();
|
||||||
try {
|
try {
|
||||||
thread.join(1500);
|
thread.join(500);
|
||||||
} catch (InterruptedException ignored) {
|
} catch (InterruptedException ignored) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
thread = null;
|
thread = null;
|
||||||
}
|
}
|
||||||
queue.clear();
|
|
||||||
session = null;
|
session = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,17 +76,12 @@ public final class CastSendPump {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
|
||||||
if (!queue.offer(item)) {
|
if (!videoQueue.offer(item)) {
|
||||||
if (!keyFrame) {
|
if (!keyFrame) {
|
||||||
Outgoing dropped = queue.poll();
|
dropOneVideoPFrame();
|
||||||
if (dropped != null && !dropped.keyFrame) {
|
|
||||||
droppedPFrames++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!queue.offer(item)) {
|
if (!videoQueue.offer(item) && !keyFrame) {
|
||||||
if (!keyFrame) {
|
droppedPFrames++;
|
||||||
droppedPFrames++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,15 +91,43 @@ public final class CastSendPump {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Outgoing item = Outgoing.raw(type, payload);
|
Outgoing item = Outgoing.raw(type, payload);
|
||||||
while (running.get() && !queue.offer(item)) {
|
boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG;
|
||||||
queue.poll();
|
if (audio) {
|
||||||
|
if (!audioQueue.offer(item)) {
|
||||||
|
Outgoing dropped = audioQueue.poll();
|
||||||
|
if (dropped != null) {
|
||||||
|
droppedAudioFrames++;
|
||||||
|
}
|
||||||
|
audioQueue.offer(item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (running.get() && !videoQueue.offer(item)) {
|
||||||
|
dropOneVideoPFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dropOneVideoPFrame() {
|
||||||
|
for (Outgoing o : videoQueue) {
|
||||||
|
if (o.type == CastProtocol.MSG_VIDEO_FRAME && !o.keyFrame) {
|
||||||
|
videoQueue.remove(o);
|
||||||
|
droppedPFrames++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Outgoing head = videoQueue.poll();
|
||||||
|
if (head != null && head.type == CastProtocol.MSG_VIDEO_FRAME && !head.keyFrame) {
|
||||||
|
droppedPFrames++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void run() {
|
private void run() {
|
||||||
while (running.get()) {
|
while (running.get()) {
|
||||||
try {
|
try {
|
||||||
Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
|
Outgoing item = audioQueue.poll();
|
||||||
|
if (item == null) {
|
||||||
|
item = videoQueue.poll(50, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
if (item == null || session == null) {
|
if (item == null || session == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,18 +149,27 @@ public final class MultiCastCoordinator {
|
|||||||
return any;
|
return any;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void closeAll() {
|
public void sendGoodbyeAll() {
|
||||||
for (ActivePeer p : peers) {
|
for (ActivePeer p : peers) {
|
||||||
try {
|
try {
|
||||||
p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||||
} catch (IOException ignored) {
|
} catch (IOException ignored) {
|
||||||
}
|
}
|
||||||
p.session.close();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void closeAll() {
|
||||||
|
for (ActivePeer p : peers) {
|
||||||
|
try {
|
||||||
|
p.session.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
peers.clear();
|
peers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private CastSession createSession(CastSettings settings) throws IOException {
|
private CastSession createSession(CastSettings settings) throws IOException {
|
||||||
|
CastTransportFactory.init(appContext);
|
||||||
CastSession session = new CastSession(CastTransportFactory.create(settings));
|
CastSession session = new CastSession(CastTransportFactory.create(settings));
|
||||||
session.getTransport().setFramingContext(framingContext);
|
session.getTransport().setFramingContext(framingContext);
|
||||||
return session;
|
return session;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ 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.NetworkStatsSnapshot;
|
||||||
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsRecorder;
|
||||||
|
import com.foxx.androidcast.stats.SessionStatsStore;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -46,7 +50,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
|
/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
|
||||||
public class ScreenCastService extends Service {
|
public class ScreenCastService extends Service implements
|
||||||
|
com.foxx.androidcast.sender.calibration.CalibrationCastDriver.FallbackHost {
|
||||||
private static final String TAG = "ScreenCastService";
|
private static final String TAG = "ScreenCastService";
|
||||||
private static final int CONNECT_RETRIES = 20;
|
private static final int CONNECT_RETRIES = 20;
|
||||||
private static final long CONNECT_RETRY_MS = 1000;
|
private static final long CONNECT_RETRY_MS = 1000;
|
||||||
@@ -58,12 +63,14 @@ public class ScreenCastService extends Service {
|
|||||||
public static final String EXTRA_PIN = "pin";
|
public static final String EXTRA_PIN = "pin";
|
||||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||||
public static final String EXTRA_TARGETS = "cast_targets";
|
public static final String EXTRA_TARGETS = "cast_targets";
|
||||||
|
public static final String EXTRA_CALIBRATION = "calibration_mode";
|
||||||
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
|
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
|
||||||
|
|
||||||
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
|
||||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
private final AtomicBoolean stopping = new AtomicBoolean(false);
|
private final AtomicBoolean stopping = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean casting = new AtomicBoolean(false);
|
private final AtomicBoolean casting = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);
|
||||||
|
|
||||||
private MediaProjection projection;
|
private MediaProjection projection;
|
||||||
private VirtualDisplay virtualDisplay;
|
private VirtualDisplay virtualDisplay;
|
||||||
@@ -92,6 +99,11 @@ public class ScreenCastService extends Service {
|
|||||||
private int captureDensity;
|
private int captureDensity;
|
||||||
private DisplayManager displayManager;
|
private DisplayManager displayManager;
|
||||||
private DisplayManager.DisplayListener displayListener;
|
private DisplayManager.DisplayListener displayListener;
|
||||||
|
private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver;
|
||||||
|
private volatile boolean calibrationMode;
|
||||||
|
private volatile boolean cameraMode;
|
||||||
|
private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture;
|
||||||
|
private SessionStatsRecorder sessionStatsRecorder;
|
||||||
|
|
||||||
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
|
private final ICastSenderService.Stub binder = new ICastSenderService.Stub() {
|
||||||
@Override
|
@Override
|
||||||
@@ -128,7 +140,9 @@ public class ScreenCastService extends Service {
|
|||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
SessionStatsStore.pruneOnAppStart(this);
|
||||||
CastNotifications.createChannels(this);
|
CastNotifications.createChannels(this);
|
||||||
|
com.foxx.androidcast.network.CastTransportFactory.init(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -149,6 +163,8 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void runCast(Intent intent) {
|
private void runCast(Intent intent) {
|
||||||
|
resourcesReleased.set(false);
|
||||||
|
stopping.set(false);
|
||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
failAndStop(getString(R.string.error_no_intent));
|
failAndStop(getString(R.string.error_no_intent));
|
||||||
return;
|
return;
|
||||||
@@ -161,7 +177,11 @@ public class ScreenCastService extends Service {
|
|||||||
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
|
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
|
||||||
CastSettings settings = IntentExtras.getCastSettings(intent);
|
CastSettings settings = IntentExtras.getCastSettings(intent);
|
||||||
|
|
||||||
if (data == null) {
|
boolean calibration = settings != null
|
||||||
|
&& settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
|
||||||
|
boolean camera = settings != null
|
||||||
|
&& settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
|
||||||
|
if (!calibration && !camera && data == null) {
|
||||||
failAndStop(getString(R.string.error_projection_data));
|
failAndStop(getString(R.string.error_projection_data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,6 +189,16 @@ public class ScreenCastService extends Service {
|
|||||||
failAndStop(getString(R.string.error_missing_params));
|
failAndStop(getString(R.string.error_missing_params));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
|
||||||
|
calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
|
||||||
|
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
|
||||||
|
failAndStop(getString(R.string.capture_camera_not_implemented));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
|
||||||
|
if (port <= 0 || port == com.foxx.androidcast.CastConfig.CAST_PORT) {
|
||||||
|
port = castPort;
|
||||||
|
}
|
||||||
|
|
||||||
acquireWakeLock();
|
acquireWakeLock();
|
||||||
try {
|
try {
|
||||||
@@ -210,7 +240,17 @@ public class ScreenCastService extends Service {
|
|||||||
casting.set(true);
|
casting.set(true);
|
||||||
CastActiveState.setSenderCasting(true);
|
CastActiveState.setSenderCasting(true);
|
||||||
activeSettings = settings;
|
activeSettings = settings;
|
||||||
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
|
||||||
|
sessionStatsRecorder.mergeSettings(settings);
|
||||||
|
sessionStatsRecorder.setClientCount(peers.size());
|
||||||
|
sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC");
|
||||||
|
if (calibration) {
|
||||||
|
startCalibrationCaptureOnMainThread(settings);
|
||||||
|
} else if (camera) {
|
||||||
|
startCameraCaptureOnMainThread(settings);
|
||||||
|
} else {
|
||||||
|
startCaptureOnMainThread(resultCode, new Intent(data), settings);
|
||||||
|
}
|
||||||
scheduleTuningTick();
|
scheduleTuningTick();
|
||||||
while (!stopping.get()) {
|
while (!stopping.get()) {
|
||||||
try {
|
try {
|
||||||
@@ -285,6 +325,83 @@ public class ScreenCastService extends Service {
|
|||||||
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
|
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void startCalibrationCaptureOnMainThread(CastSettings settings) throws IOException {
|
||||||
|
if (Looper.myLooper() != mainHandler.getLooper()) {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<IOException> error = new AtomicReference<>();
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
try {
|
||||||
|
startCalibrationCaptureOnMainThread(settings);
|
||||||
|
} catch (Exception e) {
|
||||||
|
error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e));
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (!latch.await(15, TimeUnit.SECONDS)) {
|
||||||
|
throw new IOException("Calibration setup timeout");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Interrupted", e);
|
||||||
|
}
|
||||||
|
if (error.get() != null) {
|
||||||
|
throw error.get();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CastResolution.Size size = CastResolution.calibrationTestSize();
|
||||||
|
setupVideoPipeline(size, settings, true);
|
||||||
|
startAudioPipeline(settings, true);
|
||||||
|
updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startCameraCaptureOnMainThread(CastSettings settings) throws IOException {
|
||||||
|
if (Looper.myLooper() != mainHandler.getLooper()) {
|
||||||
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
|
AtomicReference<IOException> error = new AtomicReference<>();
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
try {
|
||||||
|
startCameraCaptureOnMainThread(settings);
|
||||||
|
} catch (Exception e) {
|
||||||
|
error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e));
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (!latch.await(20, TimeUnit.SECONDS)) {
|
||||||
|
throw new IOException("Camera setup timeout");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Interrupted", e);
|
||||||
|
}
|
||||||
|
if (error.get() != null) {
|
||||||
|
throw error.get();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
|
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
|
||||||
|
wm.getDefaultDisplay().getRealMetrics(metrics);
|
||||||
|
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
|
||||||
|
setupVideoPipeline(size, settings, false);
|
||||||
|
Surface surface = videoEncoder != null ? videoEncoder.getInputSurface() : null;
|
||||||
|
if (surface == null) {
|
||||||
|
throw new IOException("Encoder surface unavailable for camera");
|
||||||
|
}
|
||||||
|
cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this);
|
||||||
|
try {
|
||||||
|
cameraCapture.start(surface, captureWidth, captureHeight);
|
||||||
|
} catch (android.hardware.camera2.CameraAccessException e) {
|
||||||
|
throw new IOException("Camera open failed: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
startAudioPipeline(settings, true);
|
||||||
|
updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight));
|
||||||
|
}
|
||||||
|
|
||||||
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
|
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
|
||||||
if (Looper.myLooper() != mainHandler.getLooper()) {
|
if (Looper.myLooper() != mainHandler.getLooper()) {
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
CountDownLatch latch = new CountDownLatch(1);
|
||||||
@@ -335,16 +452,17 @@ public class ScreenCastService extends Service {
|
|||||||
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, getPeerStats());
|
CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
|
||||||
setupVideoPipeline(size, settings);
|
setupVideoPipeline(size, settings, false);
|
||||||
registerDisplayListener();
|
registerDisplayListener();
|
||||||
|
|
||||||
startAudioPipeline(settings);
|
startAudioPipeline(settings, false);
|
||||||
|
|
||||||
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, boolean calibration)
|
||||||
|
throws IOException {
|
||||||
if (settings.isPassthrough()) {
|
if (settings.isPassthrough()) {
|
||||||
throw new IOException("Passthrough video is not implemented yet (debug only)");
|
throw new IOException("Passthrough video is not implemented yet (debug only)");
|
||||||
}
|
}
|
||||||
@@ -376,45 +494,29 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
videoEncoder = new VideoEncoder();
|
videoEncoder = new VideoEncoder();
|
||||||
VideoEncoder.Callback videoCb = new VideoEncoder.Callback() {
|
VideoEncoder.Callback videoCb = buildVideoEncoderCallback(mime);
|
||||||
@Override
|
|
||||||
public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
|
|
||||||
if (stopping.get()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
|
|
||||||
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
if (calibration) {
|
||||||
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
startCalibrationVideoEncoder(mime, effectiveParams, videoCb, true);
|
||||||
if ((sendPump == null && fanoutPump == null) || frameData == null) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
|
|
||||||
&& !keyFrame
|
|
||||||
&& frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
|
|
||||||
if (tiny) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastSignificantFrameMs = System.currentTimeMillis();
|
|
||||||
senderMetrics.onEncodedFrame(keyFrame);
|
|
||||||
enqueueVideoFrame(ptsUs, keyFrame, frameData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
|
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
|
||||||
encoderKeepalive = new EncoderKeepalive();
|
encoderKeepalive = new EncoderKeepalive();
|
||||||
encoderKeepalive.start(videoEncoder);
|
encoderKeepalive.start(videoEncoder);
|
||||||
virtualDisplay = projection.createVirtualDisplay(
|
if (projection != null) {
|
||||||
"AndroidCast",
|
virtualDisplay = projection.createVirtualDisplay(
|
||||||
captureWidth,
|
"AndroidCast",
|
||||||
captureHeight,
|
captureWidth,
|
||||||
captureDensity,
|
captureHeight,
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
captureDensity,
|
||||||
surface,
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||||
null,
|
surface,
|
||||||
null);
|
null,
|
||||||
|
null);
|
||||||
|
} else {
|
||||||
|
throw new IOException("MediaProjection required for screen capture");
|
||||||
|
}
|
||||||
videoEncoder.requestKeyframe();
|
videoEncoder.requestKeyframe();
|
||||||
Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight);
|
Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight);
|
||||||
}
|
}
|
||||||
@@ -450,7 +552,8 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void maybeReconfigureForRotation() {
|
private void maybeReconfigureForRotation() {
|
||||||
if (!casting.get() || projection == null || activeSettings == null || stopping.get()) {
|
if (calibrationMode || cameraMode || !casting.get() || projection == null
|
||||||
|
|| activeSettings == null || stopping.get()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DisplayMetrics metrics = new DisplayMetrics();
|
DisplayMetrics metrics = new DisplayMetrics();
|
||||||
@@ -461,7 +564,7 @@ public class ScreenCastService extends Service {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setupVideoPipeline(size, activeSettings);
|
setupVideoPipeline(size, activeSettings, false);
|
||||||
updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight));
|
updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.e(TAG, "Rotation reconfigure failed", e);
|
Log.e(TAG, "Rotation reconfigure failed", e);
|
||||||
@@ -484,6 +587,11 @@ public class ScreenCastService extends Service {
|
|||||||
s.encodeQueueDepth = sendPump.getQueueDepth();
|
s.encodeQueueDepth = sendPump.getQueueDepth();
|
||||||
s.droppedPFrames = sendPump.getDroppedPFrames();
|
s.droppedPFrames = sendPump.getDroppedPFrames();
|
||||||
}
|
}
|
||||||
|
TransportLossStats snap = buildSenderLossSnapshot();
|
||||||
|
TransportLossStatsBridge.applyTo(s, snap);
|
||||||
|
if (sessionStatsRecorder != null) {
|
||||||
|
sessionStatsRecorder.sampleNetwork(s, getPeerStats());
|
||||||
|
}
|
||||||
s.audioActive = audioEncoder != null && audioFramesSent > 0;
|
s.audioActive = audioEncoder != null && audioFramesSent > 0;
|
||||||
s.audioFramesSent = audioFramesSent;
|
s.audioFramesSent = audioFramesSent;
|
||||||
s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent();
|
s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent();
|
||||||
@@ -543,7 +651,7 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) {
|
private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) {
|
||||||
if (activeSettings == null
|
if (calibrationMode || cameraMode || activeSettings == null
|
||||||
|| activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) {
|
|| activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -557,19 +665,19 @@ public class ScreenCastService extends Service {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setupVideoPipeline(size, activeSettings);
|
setupVideoPipeline(size, activeSettings, false);
|
||||||
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
|
Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, "Adaptive resize failed", e);
|
Log.w(TAG, "Adaptive resize failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startAudioPipeline(CastSettings settings) {
|
private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) {
|
||||||
if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
Log.i(TAG, "Audio capture disabled for this session");
|
Log.i(TAG, "Audio capture disabled for this session");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (projection == null) {
|
if (projection == null && !calibrationOnlyMic) {
|
||||||
Log.w(TAG, "Audio skipped — no MediaProjection");
|
Log.w(TAG, "Audio skipped — no MediaProjection");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -609,11 +717,15 @@ public class ScreenCastService extends Service {
|
|||||||
senderAudioMetrics.reset();
|
senderAudioMetrics.reset();
|
||||||
audioCaptureMode = "playback";
|
audioCaptureMode = "playback";
|
||||||
audioCapture = new AudioCapture(this);
|
audioCapture = new AudioCapture(this);
|
||||||
audioCapture.start(projection, new AudioCapture.Callback() {
|
MediaProjection audioProjection = calibrationOnlyMic ? null : projection;
|
||||||
|
audioCapture.start(audioProjection, new AudioCapture.Callback() {
|
||||||
@Override
|
@Override
|
||||||
public void onPcm(byte[] pcm, int length, long ptsUs,
|
public void onPcm(byte[] pcm, int length, long ptsUs,
|
||||||
com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
|
com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
|
||||||
senderAudioMetrics.onPcm(levels);
|
senderAudioMetrics.onPcm(levels);
|
||||||
|
if (calibrationDriver != null) {
|
||||||
|
calibrationDriver.setVuFromPcm(pcm, length);
|
||||||
|
}
|
||||||
if (audioEncoder != null) {
|
if (audioEncoder != null) {
|
||||||
audioEncoder.queuePcm(pcm, length, ptsUs);
|
audioEncoder.queuePcm(pcm, length, ptsUs);
|
||||||
}
|
}
|
||||||
@@ -634,7 +746,8 @@ public class ScreenCastService extends Service {
|
|||||||
private void handleAudioConsent(boolean accept) {
|
private void handleAudioConsent(boolean accept) {
|
||||||
if (accept) {
|
if (accept) {
|
||||||
if (audioEncoder == null && activeSettings != null) {
|
if (audioEncoder == null && activeSettings != null) {
|
||||||
startAudioPipeline(activeSettings);
|
startAudioPipeline(activeSettings, activeSettings != null
|
||||||
|
&& activeSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST);
|
||||||
}
|
}
|
||||||
Log.i(TAG, "Receiver accepted audio");
|
Log.i(TAG, "Receiver accepted audio");
|
||||||
} else {
|
} else {
|
||||||
@@ -695,6 +808,10 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void releaseAll() {
|
private void releaseAll() {
|
||||||
|
if (!resourcesReleased.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishSessionStatsAsync();
|
||||||
stopping.set(true);
|
stopping.set(true);
|
||||||
casting.set(false);
|
casting.set(false);
|
||||||
CastActiveState.setSenderCasting(false);
|
CastActiveState.setSenderCasting(false);
|
||||||
@@ -708,10 +825,16 @@ public class ScreenCastService extends Service {
|
|||||||
encoderKeepalive.stop();
|
encoderKeepalive.stop();
|
||||||
encoderKeepalive = null;
|
encoderKeepalive = null;
|
||||||
}
|
}
|
||||||
if (videoEncoder != null) {
|
if (cameraCapture != null) {
|
||||||
videoEncoder.stop();
|
cameraCapture.stop();
|
||||||
videoEncoder = null;
|
cameraCapture = null;
|
||||||
}
|
}
|
||||||
|
if (calibrationDriver != null) {
|
||||||
|
calibrationDriver.release();
|
||||||
|
calibrationDriver = null;
|
||||||
|
}
|
||||||
|
calibrationMode = false;
|
||||||
|
cameraMode = false;
|
||||||
if (audioCapture != null) {
|
if (audioCapture != null) {
|
||||||
audioCapture.stop();
|
audioCapture.stop();
|
||||||
audioCapture = null;
|
audioCapture = null;
|
||||||
@@ -720,28 +843,36 @@ public class ScreenCastService extends Service {
|
|||||||
audioEncoder.stop();
|
audioEncoder.stop();
|
||||||
audioEncoder = null;
|
audioEncoder = null;
|
||||||
}
|
}
|
||||||
|
if (videoEncoder != null) {
|
||||||
|
videoEncoder.stop();
|
||||||
|
videoEncoder = null;
|
||||||
|
}
|
||||||
if (projection != null) {
|
if (projection != null) {
|
||||||
projection.stop();
|
projection.stop();
|
||||||
projection = null;
|
projection = null;
|
||||||
}
|
}
|
||||||
if (multiCoordinator != null) {
|
if (multiCoordinator != null) {
|
||||||
multiCoordinator.closeAll();
|
multiCoordinator.sendGoodbyeAll();
|
||||||
multiCoordinator = null;
|
|
||||||
} else if (session != null) {
|
} else if (session != null) {
|
||||||
try {
|
try {
|
||||||
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
session.close();
|
}
|
||||||
session = null;
|
if (sendPump != null) {
|
||||||
|
sendPump.stop();
|
||||||
|
sendPump = null;
|
||||||
}
|
}
|
||||||
if (fanoutPump != null) {
|
if (fanoutPump != null) {
|
||||||
fanoutPump.stop();
|
fanoutPump.stop();
|
||||||
fanoutPump = null;
|
fanoutPump = null;
|
||||||
}
|
}
|
||||||
if (sendPump != null) {
|
if (multiCoordinator != null) {
|
||||||
sendPump.stop();
|
multiCoordinator.closeAll();
|
||||||
sendPump = null;
|
multiCoordinator = null;
|
||||||
|
} else if (session != null) {
|
||||||
|
session.close();
|
||||||
|
session = null;
|
||||||
}
|
}
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
}
|
}
|
||||||
@@ -752,10 +883,17 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void stopSelfSafely() {
|
private void stopSelfSafely() {
|
||||||
stopping.set(true);
|
if (!stopping.compareAndSet(false, true)) {
|
||||||
releaseAll();
|
return;
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
}
|
||||||
stopSelf();
|
Thread stopper = new Thread(() -> {
|
||||||
|
releaseAll();
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||||
|
stopSelf();
|
||||||
|
});
|
||||||
|
}, "CastStop");
|
||||||
|
stopper.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startSenderForeground(String text, boolean withAudio) {
|
private void startSenderForeground(String text, boolean withAudio) {
|
||||||
@@ -771,6 +909,41 @@ public class ScreenCastService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TransportLossStats buildSenderLossSnapshot() {
|
||||||
|
TransportLossStats snap = session != null
|
||||||
|
? session.getTransport().getLossStats() : new TransportLossStats();
|
||||||
|
if (sendPump != null) {
|
||||||
|
snap.sendDroppedVideoP = sendPump.getDroppedPFrames();
|
||||||
|
snap.sendDroppedAudio = sendPump.getDroppedAudioFrames();
|
||||||
|
snap.sendQueueDepth = sendPump.getQueueDepth();
|
||||||
|
} else if (fanoutPump != null) {
|
||||||
|
snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
|
||||||
|
snap.sendQueueDepth = fanoutPump.getQueueDepth();
|
||||||
|
}
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishSessionStatsAsync() {
|
||||||
|
final SessionStatsRecorder recorder = sessionStatsRecorder;
|
||||||
|
sessionStatsRecorder = null;
|
||||||
|
if (recorder == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeSettings != null) {
|
||||||
|
recorder.setResolution(captureWidth, captureHeight);
|
||||||
|
recorder.setCaptureMode(activeSettings.getCaptureMode() != null
|
||||||
|
? activeSettings.getCaptureMode().name() : "");
|
||||||
|
}
|
||||||
|
recorder.mergeLoss(buildSenderLossSnapshot());
|
||||||
|
new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logEncodedCodecSample(String mime, byte[] frameData) {
|
||||||
|
String probe = com.foxx.androidcast.media.EncodedStreamProbe.describeKeyframe(mime, frameData);
|
||||||
|
Log.i(TAG, "Encoded stream: " + com.foxx.androidcast.media.CodecNegotiator.displayName(mime)
|
||||||
|
+ " — " + probe);
|
||||||
|
}
|
||||||
|
|
||||||
private void updateStatus(String text) {
|
private void updateStatus(String text) {
|
||||||
status = text;
|
status = text;
|
||||||
broadcastStatus(text);
|
broadcastStatus(text);
|
||||||
@@ -790,6 +963,90 @@ public class ScreenCastService extends Service {
|
|||||||
callbacks.finishBroadcast();
|
callbacks.finishBroadcast();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private VideoEncoder.Callback buildVideoEncoderCallback(String mime) {
|
||||||
|
return new VideoEncoder.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
|
||||||
|
if (stopping.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendSafe(CastProtocol.MSG_STREAM_CONFIG,
|
||||||
|
() -> CastProtocol.streamConfigPayload(w, h, csd0, csd1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
|
||||||
|
if ((sendPump == null && fanoutPump == null) || frameData == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
|
||||||
|
&& !keyFrame
|
||||||
|
&& frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
|
||||||
|
if (tiny) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSignificantFrameMs = System.currentTimeMillis();
|
||||||
|
senderMetrics.onEncodedFrame(keyFrame);
|
||||||
|
if (keyFrame) {
|
||||||
|
logEncodedCodecSample(mime, frameData);
|
||||||
|
}
|
||||||
|
enqueueVideoFrame(ptsUs, keyFrame, frameData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startCalibrationVideoEncoder(String mime, CastTuningEngine.EffectiveParams params,
|
||||||
|
VideoEncoder.Callback videoCb, boolean tryGles) throws IOException {
|
||||||
|
if (encoderKeepalive != null) {
|
||||||
|
encoderKeepalive.stop();
|
||||||
|
encoderKeepalive = null;
|
||||||
|
}
|
||||||
|
if (calibrationDriver != null) {
|
||||||
|
calibrationDriver.release();
|
||||||
|
calibrationDriver = null;
|
||||||
|
}
|
||||||
|
if (videoEncoder != null) {
|
||||||
|
videoEncoder.stop();
|
||||||
|
}
|
||||||
|
videoEncoder = new VideoEncoder();
|
||||||
|
float density = getResources().getDisplayMetrics().density;
|
||||||
|
calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
|
||||||
|
if (tryGles) {
|
||||||
|
Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb);
|
||||||
|
encoderKeepalive = new EncoderKeepalive();
|
||||||
|
encoderKeepalive.start(videoEncoder);
|
||||||
|
calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density);
|
||||||
|
videoEncoder.requestKeyframe();
|
||||||
|
Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime);
|
||||||
|
} else {
|
||||||
|
videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb);
|
||||||
|
encoderKeepalive = new EncoderKeepalive();
|
||||||
|
encoderKeepalive.start(videoEncoder);
|
||||||
|
calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density);
|
||||||
|
videoEncoder.requestKeyframe();
|
||||||
|
Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCalibrationNeedBufferEncoder() {
|
||||||
|
if (!calibrationMode || stopping.get() || activeSettings == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String mime = activeSettings.getNegotiatedVideoMime();
|
||||||
|
if (mime == null || mime.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
try {
|
||||||
|
startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.e(TAG, "Calibration buffer fallback failed", e);
|
||||||
|
failAndStop("Calibration video failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public IBinder onBind(Intent intent) {
|
public IBinder onBind(Intent intent) {
|
||||||
return binder;
|
return binder;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import android.provider.Settings;
|
|||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
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.ProgressBar;
|
||||||
@@ -35,7 +36,6 @@ 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 long BACK_STOP_INTERVAL_MS = 2_000;
|
private static final long BACK_STOP_INTERVAL_MS = 2_000;
|
||||||
private static final int DEV_TAP_UNLOCK = 7;
|
private static final int DEV_TAP_UNLOCK = 7;
|
||||||
|
|
||||||
@@ -47,7 +47,28 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
private TextView statusText;
|
private TextView statusText;
|
||||||
private TextView titleText;
|
private TextView titleText;
|
||||||
private ProgressBar discoveryProgress;
|
private ProgressBar discoveryProgress;
|
||||||
|
private Button refreshButton;
|
||||||
private View advancedPanel;
|
private View advancedPanel;
|
||||||
|
|
||||||
|
private enum DiscoveryPhase {
|
||||||
|
IDLE, SCANNING, PAUSED
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
|
||||||
|
private int discoverySecondsLeft;
|
||||||
|
private final Runnable autoScanRunnable = () -> beginScanPass(false);
|
||||||
|
private final Runnable discoveryCountdownTick = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
|
||||||
|
if (discoverySecondsLeft > 0) {
|
||||||
|
discoverySecondsLeft--;
|
||||||
|
}
|
||||||
|
updateRefreshButtonLabel();
|
||||||
|
handler.postDelayed(this, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
private EditText pinInput;
|
private EditText pinInput;
|
||||||
private EditText manualHostInput;
|
private EditText manualHostInput;
|
||||||
private EditText manualPortInput;
|
private EditText manualPortInput;
|
||||||
@@ -57,15 +78,6 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
private boolean useManualConnect;
|
private boolean useManualConnect;
|
||||||
private long lastBackPressMs;
|
private long lastBackPressMs;
|
||||||
|
|
||||||
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) {
|
||||||
@@ -79,6 +91,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
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);
|
discoveryProgress = findViewById(R.id.progress_discovery);
|
||||||
|
refreshButton = findViewById(R.id.btn_refresh);
|
||||||
advancedPanel = findViewById(R.id.panel_advanced);
|
advancedPanel = findViewById(R.id.panel_advanced);
|
||||||
manualHostInput = findViewById(R.id.edit_manual_host);
|
manualHostInput = findViewById(R.id.edit_manual_host);
|
||||||
manualPortInput = findViewById(R.id.edit_manual_port);
|
manualPortInput = findViewById(R.id.edit_manual_port);
|
||||||
@@ -143,12 +156,22 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onScanStarted() {
|
public void onScanStarted() {
|
||||||
|
discoveryPhase = DiscoveryPhase.SCANNING;
|
||||||
|
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
|
||||||
|
updateRefreshButtonLabel();
|
||||||
|
handler.removeCallbacks(discoveryCountdownTick);
|
||||||
|
handler.post(discoveryCountdownTick);
|
||||||
showDiscoveryProgress();
|
showDiscoveryProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onScanEnded() {
|
public void onScanEnded() {
|
||||||
hideDiscoveryProgress();
|
hideDiscoveryProgress();
|
||||||
|
discoveryPhase = DiscoveryPhase.PAUSED;
|
||||||
|
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000);
|
||||||
|
updateRefreshButtonLabel();
|
||||||
|
handler.removeCallbacks(autoScanRunnable);
|
||||||
|
handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +193,7 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery());
|
refreshButton.setOnClickListener(v -> onRefreshClicked());
|
||||||
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 -> {
|
findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
|
||||||
@@ -188,36 +211,60 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
restartDiscovery();
|
beginScanPass(false);
|
||||||
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStop() {
|
protected void onStop() {
|
||||||
handler.removeCallbacks(discoveryWatchdog);
|
handler.removeCallbacks(discoveryCountdownTick);
|
||||||
|
handler.removeCallbacks(autoScanRunnable);
|
||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
}
|
}
|
||||||
|
discoveryPhase = DiscoveryPhase.IDLE;
|
||||||
|
updateRefreshButtonLabel();
|
||||||
hideDiscoveryProgress();
|
hideDiscoveryProgress();
|
||||||
super.onStop();
|
super.onStop();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void restartDiscovery() {
|
private void onRefreshClicked() {
|
||||||
handler.removeCallbacks(discoveryWatchdog);
|
if (discoveryPhase == DiscoveryPhase.SCANNING) {
|
||||||
restartDiscoveryInternal(true);
|
return;
|
||||||
handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS);
|
}
|
||||||
|
handler.removeCallbacks(autoScanRunnable);
|
||||||
|
beginScanPass(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void restartDiscoveryInternal(boolean userRefresh) {
|
private void beginScanPass(boolean userInitiated) {
|
||||||
deviceAdapter.clearDevices();
|
if (discovery != null && discovery.isBrowsing()) {
|
||||||
statusText.setText(userRefresh ? R.string.rescanning : R.string.searching);
|
return;
|
||||||
|
}
|
||||||
|
if (userInitiated) {
|
||||||
|
deviceAdapter.clearDevices();
|
||||||
|
statusText.setText(R.string.rescanning);
|
||||||
|
} else if (deviceAdapter.getCount() == 0) {
|
||||||
|
statusText.setText(R.string.searching);
|
||||||
|
}
|
||||||
if (discovery != null) {
|
if (discovery != null) {
|
||||||
discovery.stop();
|
discovery.stop();
|
||||||
discovery.clearDevices();
|
if (userInitiated) {
|
||||||
|
discovery.clearDevices();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
discovery.startBrowsing();
|
discovery.startBrowsing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateRefreshButtonLabel() {
|
||||||
|
if (refreshButton == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
|
||||||
|
refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft)));
|
||||||
|
} else {
|
||||||
|
refreshButton.setText(R.string.refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void showDiscoveryProgress() {
|
private void showDiscoveryProgress() {
|
||||||
discoveryProgress.setVisibility(View.VISIBLE);
|
discoveryProgress.setVisibility(View.VISIBLE);
|
||||||
discoveryProgress.bringToFront();
|
discoveryProgress.bringToFront();
|
||||||
@@ -303,6 +350,21 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingPin = pin;
|
pendingPin = pin;
|
||||||
|
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
|
||||||
|
launchCastService(null, Activity.RESULT_OK);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) {
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
|
||||||
|
android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
androidx.core.app.ActivityCompat.requestPermissions(this,
|
||||||
|
new String[] {android.Manifest.permission.CAMERA}, PermissionHelper.REQUEST_CODE);
|
||||||
|
pendingCameraLaunch = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
launchCastService(null, Activity.RESULT_OK);
|
||||||
|
return;
|
||||||
|
}
|
||||||
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
|
||||||
startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
|
startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
|
||||||
}
|
}
|
||||||
@@ -316,39 +378,61 @@ public class SenderActivity extends AppCompatActivity {
|
|||||||
return mgr.createScreenCaptureIntent();
|
return mgr.createScreenCaptureIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean pendingCameraLaunch;
|
||||||
|
|
||||||
private String pendingPin;
|
private String pendingPin;
|
||||||
private String pendingHost;
|
private String pendingHost;
|
||||||
private int pendingPort;
|
private int pendingPort;
|
||||||
private String pendingDeviceName;
|
private String pendingDeviceName;
|
||||||
private java.util.ArrayList<CastReceiverTarget> pendingTargets;
|
private java.util.ArrayList<CastReceiverTarget> pendingTargets;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
if (requestCode == PermissionHelper.REQUEST_CODE && pendingCameraLaunch) {
|
||||||
|
pendingCameraLaunch = false;
|
||||||
|
if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
launchCastService(null, Activity.RESULT_OK);
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, R.string.capture_camera_not_implemented, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
if (requestCode != REQUEST_MEDIA_PROJECTION) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) {
|
if (resultCode != Activity.RESULT_OK || pendingHost == 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;
|
||||||
}
|
}
|
||||||
|
launchCastService(data, resultCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void launchCastService(Intent projectionData, int resultCode) {
|
||||||
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
|
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
|
||||||
if (deviceName == null) {
|
if (deviceName == null) {
|
||||||
deviceName = Build.MODEL;
|
deviceName = Build.MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
if (projectionData != null) {
|
||||||
|
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(projectionData));
|
||||||
|
}
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
|
||||||
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
|
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
|
||||||
|
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
|
||||||
|
serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true);
|
||||||
|
}
|
||||||
if (pendingTargets != null && !pendingTargets.isEmpty()) {
|
if (pendingTargets != null && !pendingTargets.isEmpty()) {
|
||||||
serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets);
|
serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(serviceIntent);
|
startForegroundService(serviceIntent);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.foxx.androidcast.sender;
|
package com.foxx.androidcast.sender;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
import android.media.MediaCodec;
|
import android.media.MediaCodec;
|
||||||
import android.media.MediaCodecInfo;
|
import android.media.MediaCodecInfo;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
@@ -9,6 +10,7 @@ import android.util.Log;
|
|||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
|
|
||||||
import com.foxx.androidcast.CastTuningEngine;
|
import com.foxx.androidcast.CastTuningEngine;
|
||||||
|
import com.foxx.androidcast.media.RgbToYuv420;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
@@ -25,6 +27,9 @@ public class VideoEncoder {
|
|||||||
|
|
||||||
private MediaCodec codec;
|
private MediaCodec codec;
|
||||||
private Surface inputSurface;
|
private Surface inputSurface;
|
||||||
|
private boolean bufferInput;
|
||||||
|
private RgbToYuv420.Layout yuvLayout = RgbToYuv420.Layout.I420;
|
||||||
|
private byte[] yuvScratch;
|
||||||
private Callback callback;
|
private Callback callback;
|
||||||
private int width;
|
private int width;
|
||||||
private int height;
|
private int height;
|
||||||
@@ -32,7 +37,10 @@ public class VideoEncoder {
|
|||||||
private Thread drainThread;
|
private Thread drainThread;
|
||||||
private String mime;
|
private String mime;
|
||||||
|
|
||||||
/** Request an IDR (keyframe) on the next opportunity. */
|
public Surface getInputSurface() {
|
||||||
|
return inputSurface;
|
||||||
|
}
|
||||||
|
|
||||||
public void requestKeyframe() {
|
public void requestKeyframe() {
|
||||||
if (codec == null || !running) {
|
if (codec == null || !running) {
|
||||||
return;
|
return;
|
||||||
@@ -48,19 +56,36 @@ public class VideoEncoder {
|
|||||||
|
|
||||||
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||||
Callback callback) throws IOException {
|
Callback callback) throws IOException {
|
||||||
|
return prepareInternal(width, height, mime, params, callback, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Software-fed frames (calibration chart) via YUV buffers — no input Surface. */
|
||||||
|
public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||||
|
Callback callback) throws IOException {
|
||||||
|
prepareInternal(width, height, mime, params, callback, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Surface prepareInternal(int width, int height, String mime, CastTuningEngine.EffectiveParams params,
|
||||||
|
Callback callback, boolean buffer) throws IOException {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
this.height = height;
|
||||||
this.mime = mime;
|
this.mime = mime;
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
|
this.bufferInput = buffer;
|
||||||
MediaFormat tuned = buildFormat(width, height, mime, params, true);
|
yuvScratch = buffer ? new byte[RgbToYuv420.bufferSize(width, height)] : null;
|
||||||
MediaFormat basic = buildFormat(width, height, mime, params, false);
|
|
||||||
|
|
||||||
codec = MediaCodec.createEncoderByType(mime);
|
codec = MediaCodec.createEncoderByType(mime);
|
||||||
|
yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
|
||||||
|
|
||||||
|
MediaFormat tuned = buildFormat(width, height, mime, params, true, buffer, yuvLayout);
|
||||||
|
MediaFormat basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
|
||||||
|
|
||||||
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);
|
||||||
|
yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420;
|
||||||
|
basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout);
|
||||||
if (!tryConfigure(codec, basic)) {
|
if (!tryConfigure(codec, basic)) {
|
||||||
codec.release();
|
codec.release();
|
||||||
codec = null;
|
codec = null;
|
||||||
@@ -68,20 +93,73 @@ public class VideoEncoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inputSurface = codec.createInputSurface();
|
if (bufferInput) {
|
||||||
|
inputSurface = null;
|
||||||
|
} else {
|
||||||
|
inputSurface = codec.createInputSurface();
|
||||||
|
}
|
||||||
codec.start();
|
codec.start();
|
||||||
running = true;
|
running = true;
|
||||||
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
|
||||||
drainThread.start();
|
drainThread.start();
|
||||||
Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps "
|
Log.i(TAG, (bufferInput ? "Buffer/" + yuvLayout : "Surface") + " encoder " + mime
|
||||||
+ (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec);
|
+ " " + width + "x" + height + " @ " + params.videoFps + "fps");
|
||||||
return inputSurface;
|
return inputSurface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RgbToYuv420.Layout pickYuvLayout(MediaCodec codec, String mime) {
|
||||||
|
MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime);
|
||||||
|
for (int fmt : caps.colorFormats) {
|
||||||
|
if (fmt == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) {
|
||||||
|
return RgbToYuv420.Layout.NV12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RgbToYuv420.Layout.I420;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void queueBitmapFrame(Bitmap bitmap, long ptsUs) {
|
||||||
|
if (!bufferInput || codec == null || !running || bitmap == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] yuv = RgbToYuv420.fromBitmap(bitmap, yuvLayout);
|
||||||
|
queueYuvFrame(yuv, ptsUs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void queueYuvFrame(byte[] yuv, long ptsUs) {
|
||||||
|
if (!bufferInput || codec == null || !running || yuv == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int index = codec.dequeueInputBuffer(0);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ByteBuffer buffer = codec.getInputBuffer(index);
|
||||||
|
if (buffer == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buffer.clear();
|
||||||
|
int len = Math.min(buffer.remaining(), yuv.length);
|
||||||
|
buffer.put(yuv, 0, len);
|
||||||
|
codec.queueInputBuffer(index, 0, len, ptsUs, 0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "queueYuvFrame: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static MediaFormat buildFormat(int width, int height, String mime,
|
private static MediaFormat buildFormat(int width, int height, String mime,
|
||||||
CastTuningEngine.EffectiveParams params, boolean tuned) {
|
CastTuningEngine.EffectiveParams params, boolean tuned, boolean buffer,
|
||||||
|
RgbToYuv420.Layout layout) {
|
||||||
MediaFormat format = MediaFormat.createVideoFormat(mime, width, height);
|
MediaFormat format = MediaFormat.createVideoFormat(mime, width, height);
|
||||||
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
|
int color;
|
||||||
|
if (!buffer) {
|
||||||
|
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface;
|
||||||
|
} else if (layout == RgbToYuv420.Layout.NV12) {
|
||||||
|
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar;
|
||||||
|
} else {
|
||||||
|
color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar;
|
||||||
|
}
|
||||||
|
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, color);
|
||||||
format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate);
|
format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate);
|
||||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps);
|
format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps);
|
||||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec);
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec);
|
||||||
@@ -117,6 +195,7 @@ public class VideoEncoder {
|
|||||||
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
|
bufferInput = false;
|
||||||
if (drainThread != null) {
|
if (drainThread != null) {
|
||||||
try {
|
try {
|
||||||
drainThread.join(1500);
|
drainThread.join(1500);
|
||||||
@@ -136,25 +215,30 @@ public class VideoEncoder {
|
|||||||
inputSurface.release();
|
inputSurface.release();
|
||||||
inputSurface = null;
|
inputSurface = null;
|
||||||
}
|
}
|
||||||
|
yuvScratch = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drainLoop() {
|
private void drainLoop() {
|
||||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||||
boolean configSent = false;
|
boolean configSent = false;
|
||||||
while (running) {
|
while (running) {
|
||||||
int index = codec.dequeueOutputBuffer(info, 5_000);
|
MediaCodec c = codec;
|
||||||
|
if (c == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
int index = c.dequeueOutputBuffer(info, 5_000);
|
||||||
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||||
sendConfig(codec.getOutputFormat(), configSent);
|
sendConfig(c.getOutputFormat(), configSent);
|
||||||
configSent = true;
|
configSent = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ByteBuffer buffer = codec.getOutputBuffer(index);
|
ByteBuffer buffer = c.getOutputBuffer(index);
|
||||||
if (buffer != null && info.size > 0) {
|
if (buffer != null && info.size > 0) {
|
||||||
byte[] data = new byte[info.size];
|
byte[] data = new byte[info.size];
|
||||||
buffer.position(info.offset);
|
buffer.position(info.offset);
|
||||||
@@ -166,7 +250,7 @@ public class VideoEncoder {
|
|||||||
callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data);
|
callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
codec.releaseOutputBuffer(index, false);
|
c.releaseOutputBuffer(index, false);
|
||||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package com.foxx.androidcast.sender.calibration;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.Surface;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.media.PcmLevelAnalyzer;
|
||||||
|
import com.foxx.androidcast.sender.VideoEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders calibration chart + VU border into the video encoder (GLES surface or YUV buffer fallback).
|
||||||
|
*/
|
||||||
|
public final class CalibrationCastDriver {
|
||||||
|
private static final String TAG = "CalibrationCastDriver";
|
||||||
|
private static final int EGL_FAIL_THRESHOLD = 5;
|
||||||
|
|
||||||
|
public interface FallbackHost {
|
||||||
|
/** Recreate encoder with {@link VideoEncoder#prepareBufferInput} after GLES failure. */
|
||||||
|
void onCalibrationNeedBufferEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum FeedMode {
|
||||||
|
GLES_SURFACE,
|
||||||
|
YUV_BUFFER
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private final HandlerThread thread;
|
||||||
|
private final Handler handler;
|
||||||
|
private final FallbackHost fallbackHost;
|
||||||
|
private CalibrationEglRenderer eglRenderer;
|
||||||
|
private VideoEncoder videoEncoder;
|
||||||
|
private Bitmap chartBitmap;
|
||||||
|
private Bitmap frameBitmap;
|
||||||
|
private Canvas frameCanvas;
|
||||||
|
private int width;
|
||||||
|
private int height;
|
||||||
|
private float density;
|
||||||
|
private int scrollPx;
|
||||||
|
private volatile float vuLevel;
|
||||||
|
private volatile boolean running;
|
||||||
|
private Runnable tick;
|
||||||
|
private long encodeStartNs;
|
||||||
|
private FeedMode feedMode = FeedMode.YUV_BUFFER;
|
||||||
|
private int eglFailures;
|
||||||
|
private boolean fallbackRequested;
|
||||||
|
|
||||||
|
public CalibrationCastDriver(Context context) {
|
||||||
|
this(context, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CalibrationCastDriver(Context context, FallbackHost fallbackHost) {
|
||||||
|
appContext = context.getApplicationContext();
|
||||||
|
this.fallbackHost = fallbackHost;
|
||||||
|
thread = new HandlerThread("CalibrationCast");
|
||||||
|
thread.start();
|
||||||
|
handler = new Handler(thread.getLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preferred path: GLES into encoder {@link Surface}. */
|
||||||
|
public void attachSurface(VideoEncoder encoder, Surface encoderSurface, int width, int height, float density) {
|
||||||
|
this.videoEncoder = encoder;
|
||||||
|
initBitmaps(width, height, density);
|
||||||
|
eglFailures = 0;
|
||||||
|
fallbackRequested = false;
|
||||||
|
try {
|
||||||
|
eglRenderer = new CalibrationEglRenderer();
|
||||||
|
eglRenderer.start(encoderSurface, width, height);
|
||||||
|
feedMode = FeedMode.GLES_SURFACE;
|
||||||
|
Log.i(TAG, "Calibration GLES " + width + "x" + height);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "GLES init failed: " + e.getMessage());
|
||||||
|
releaseEgl();
|
||||||
|
feedMode = FeedMode.YUV_BUFFER;
|
||||||
|
requestBufferEncoderFallback();
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
encodeStartNs = System.nanoTime();
|
||||||
|
startTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reliable path when GLES cannot use the encoder surface (e.g. EGL_BAD_ACCESS on MTK). */
|
||||||
|
public void attachBuffer(VideoEncoder encoder, int width, int height, float density) {
|
||||||
|
this.videoEncoder = encoder;
|
||||||
|
releaseEgl();
|
||||||
|
feedMode = FeedMode.YUV_BUFFER;
|
||||||
|
initBitmaps(width, height, density);
|
||||||
|
running = true;
|
||||||
|
encodeStartNs = System.nanoTime();
|
||||||
|
startTick();
|
||||||
|
Log.i(TAG, "Calibration buffer feed " + width + "x" + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initBitmaps(int w, int h, float density) {
|
||||||
|
this.width = w;
|
||||||
|
this.height = h;
|
||||||
|
this.density = density;
|
||||||
|
scrollPx = 0;
|
||||||
|
chartBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||||
|
Canvas chartCanvas = new Canvas(chartBitmap);
|
||||||
|
TvCalibrationGenerator.drawChart(chartCanvas, w, h, appContext);
|
||||||
|
frameBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||||
|
frameCanvas = new Canvas(frameBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVuFromPcm(byte[] pcm, int length) {
|
||||||
|
PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, length);
|
||||||
|
float instant = Math.max(levels.peak, levels.rms);
|
||||||
|
if (levels.silent) {
|
||||||
|
vuLevel *= 0.88f;
|
||||||
|
} else {
|
||||||
|
vuLevel = instant * 0.7f + vuLevel * 0.3f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startTick() {
|
||||||
|
tick = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (!running || frameCanvas == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long frameStart = System.nanoTime();
|
||||||
|
drawFrame();
|
||||||
|
scrollPx += Math.max(1, Math.round(1f * density));
|
||||||
|
long elapsedMs = (System.nanoTime() - frameStart) / 1_000_000L;
|
||||||
|
long delay = feedMode == FeedMode.GLES_SURFACE
|
||||||
|
? Math.max(1L, 33L - elapsedMs)
|
||||||
|
: Math.max(1L, 50L - elapsedMs);
|
||||||
|
handler.postDelayed(this, delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handler.post(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawFrame() {
|
||||||
|
frameCanvas.drawBitmap(chartBitmap, 0f, 0f, null);
|
||||||
|
TvCalibrationGenerator.drawBorderOverlay(
|
||||||
|
frameCanvas, width, height, density, scrollPx, vuLevel);
|
||||||
|
long ptsUs = (System.nanoTime() - encodeStartNs) / 1000L;
|
||||||
|
if (feedMode == FeedMode.GLES_SURFACE && eglRenderer != null && eglRenderer.isReady()) {
|
||||||
|
if (eglRenderer.draw(frameBitmap, System.nanoTime())) {
|
||||||
|
eglFailures = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eglFailures++;
|
||||||
|
if (eglFailures >= EGL_FAIL_THRESHOLD) {
|
||||||
|
Log.w(TAG, "GLES feed failed " + eglFailures + " times — switching to buffer");
|
||||||
|
releaseEgl();
|
||||||
|
feedMode = FeedMode.YUV_BUFFER;
|
||||||
|
requestBufferEncoderFallback();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (videoEncoder != null && feedMode == FeedMode.YUV_BUFFER) {
|
||||||
|
videoEncoder.queueBitmapFrame(frameBitmap, ptsUs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestBufferEncoderFallback() {
|
||||||
|
if (fallbackRequested || fallbackHost == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fallbackRequested = true;
|
||||||
|
handler.post(() -> fallbackHost.onCalibrationNeedBufferEncoder());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseEgl() {
|
||||||
|
if (eglRenderer != null) {
|
||||||
|
eglRenderer.release();
|
||||||
|
eglRenderer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
running = false;
|
||||||
|
handler.removeCallbacksAndMessages(null);
|
||||||
|
releaseEgl();
|
||||||
|
if (chartBitmap != null) {
|
||||||
|
chartBitmap.recycle();
|
||||||
|
chartBitmap = null;
|
||||||
|
}
|
||||||
|
if (frameBitmap != null) {
|
||||||
|
frameBitmap.recycle();
|
||||||
|
frameBitmap = null;
|
||||||
|
}
|
||||||
|
frameCanvas = null;
|
||||||
|
videoEncoder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void release() {
|
||||||
|
stop();
|
||||||
|
thread.quitSafely();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package com.foxx.androidcast.sender.calibration;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.opengl.EGL14;
|
||||||
|
import android.opengl.EGLConfig;
|
||||||
|
import android.opengl.EGLContext;
|
||||||
|
import android.opengl.EGLDisplay;
|
||||||
|
import android.opengl.EGLExt;
|
||||||
|
import android.opengl.EGLSurface;
|
||||||
|
import android.opengl.GLES20;
|
||||||
|
import android.opengl.GLUtils;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.Surface;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.FloatBuffer;
|
||||||
|
|
||||||
|
/** Draws a bitmap into a MediaCodec encoder input {@link Surface} via GLES2. */
|
||||||
|
final class CalibrationEglRenderer {
|
||||||
|
private static final String TAG = "CalibrationEglRenderer";
|
||||||
|
|
||||||
|
private static final String VERT =
|
||||||
|
"attribute vec4 aPosition;\n"
|
||||||
|
+ "attribute vec2 aTexCoord;\n"
|
||||||
|
+ "varying vec2 vTexCoord;\n"
|
||||||
|
+ "void main() {\n"
|
||||||
|
+ " gl_Position = aPosition;\n"
|
||||||
|
+ " vTexCoord = aTexCoord;\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
private static final String FRAG =
|
||||||
|
"precision mediump float;\n"
|
||||||
|
+ "varying vec2 vTexCoord;\n"
|
||||||
|
+ "uniform sampler2D uTexture;\n"
|
||||||
|
+ "void main() {\n"
|
||||||
|
+ " gl_FragColor = texture2D(uTexture, vTexCoord);\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
private static final float[] QUAD = {
|
||||||
|
-1f, -1f, 0f, 1f,
|
||||||
|
1f, -1f, 1f, 1f,
|
||||||
|
-1f, 1f, 0f, 0f,
|
||||||
|
1f, 1f, 1f, 0f,
|
||||||
|
};
|
||||||
|
|
||||||
|
private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY;
|
||||||
|
private EGLContext eglContext = EGL14.EGL_NO_CONTEXT;
|
||||||
|
private EGLSurface eglSurface = EGL14.EGL_NO_SURFACE;
|
||||||
|
private int program;
|
||||||
|
private int textureId;
|
||||||
|
private int aPosition;
|
||||||
|
private int aTexCoord;
|
||||||
|
private int uTexture;
|
||||||
|
private FloatBuffer quadBuffer;
|
||||||
|
private int width;
|
||||||
|
private int height;
|
||||||
|
/** {@link EGLExt#EGL_RECORDABLE_ANDROID} — required for MediaCodec input surfaces. */
|
||||||
|
private static final int EGL_RECORDABLE_ANDROID = 0x3142;
|
||||||
|
|
||||||
|
boolean isReady() {
|
||||||
|
return eglDisplay != EGL14.EGL_NO_DISPLAY && eglSurface != EGL14.EGL_NO_SURFACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void start(Surface surface, int width, int height) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
|
||||||
|
if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
|
||||||
|
throw new IllegalStateException("eglGetDisplay failed");
|
||||||
|
}
|
||||||
|
int[] version = new int[2];
|
||||||
|
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
|
||||||
|
throw new IllegalStateException("eglInitialize failed");
|
||||||
|
}
|
||||||
|
EGLConfig eglConfig = chooseConfig(eglDisplay);
|
||||||
|
int[] contextAttribs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
|
||||||
|
eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0);
|
||||||
|
if (eglContext == EGL14.EGL_NO_CONTEXT) {
|
||||||
|
throw new IllegalStateException("eglCreateContext failed");
|
||||||
|
}
|
||||||
|
int[] surfaceAttribs = {EGL14.EGL_NONE};
|
||||||
|
eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttribs, 0);
|
||||||
|
if (eglSurface == EGL14.EGL_NO_SURFACE) {
|
||||||
|
throw new IllegalStateException("eglCreateWindowSurface failed");
|
||||||
|
}
|
||||||
|
if (!makeCurrent()) {
|
||||||
|
throw new IllegalStateException("eglMakeCurrent failed: 0x"
|
||||||
|
+ Integer.toHexString(EGL14.eglGetError()));
|
||||||
|
}
|
||||||
|
program = buildProgram(VERT, FRAG);
|
||||||
|
aPosition = GLES20.glGetAttribLocation(program, "aPosition");
|
||||||
|
aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord");
|
||||||
|
uTexture = GLES20.glGetUniformLocation(program, "uTexture");
|
||||||
|
int[] textures = new int[1];
|
||||||
|
GLES20.glGenTextures(1, textures, 0);
|
||||||
|
textureId = textures[0];
|
||||||
|
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
|
||||||
|
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
|
||||||
|
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
|
||||||
|
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
|
||||||
|
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
|
||||||
|
quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
|
||||||
|
quadBuffer.put(QUAD).position(0);
|
||||||
|
GLES20.glViewport(0, 0, width, height);
|
||||||
|
Log.i(TAG, "EGL calibration renderer " + width + "x" + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return false if EGL could not present this frame (caller should fall back to buffer input). */
|
||||||
|
boolean draw(Bitmap bitmap, long ptsNs) {
|
||||||
|
if (eglDisplay == EGL14.EGL_NO_DISPLAY || bitmap == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!makeCurrent()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
GLES20.glViewport(0, 0, width, height);
|
||||||
|
GLES20.glClearColor(0f, 0f, 0f, 1f);
|
||||||
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
|
||||||
|
GLES20.glUseProgram(program);
|
||||||
|
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
|
||||||
|
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
|
||||||
|
GLES20.glUniform1i(uTexture, 0);
|
||||||
|
quadBuffer.position(0);
|
||||||
|
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
|
||||||
|
quadBuffer.position(2);
|
||||||
|
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
|
||||||
|
GLES20.glEnableVertexAttribArray(aPosition);
|
||||||
|
GLES20.glEnableVertexAttribArray(aTexCoord);
|
||||||
|
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
|
||||||
|
GLES20.glDisableVertexAttribArray(aPosition);
|
||||||
|
GLES20.glDisableVertexAttribArray(aTexCoord);
|
||||||
|
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, ptsNs);
|
||||||
|
if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) {
|
||||||
|
Log.w(TAG, "eglSwapBuffers failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean makeCurrent() {
|
||||||
|
if (eglContext.equals(EGL14.eglGetCurrentContext())
|
||||||
|
&& eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
|
||||||
|
Log.w(TAG, "eglMakeCurrent failed: 0x" + Integer.toHexString(EGL14.eglGetError()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void release() {
|
||||||
|
if (textureId != 0) {
|
||||||
|
int[] t = new int[] {textureId};
|
||||||
|
GLES20.glDeleteTextures(1, t, 0);
|
||||||
|
textureId = 0;
|
||||||
|
}
|
||||||
|
if (program != 0) {
|
||||||
|
GLES20.glDeleteProgram(program);
|
||||||
|
program = 0;
|
||||||
|
}
|
||||||
|
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
|
||||||
|
EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT);
|
||||||
|
if (eglSurface != EGL14.EGL_NO_SURFACE) {
|
||||||
|
EGL14.eglDestroySurface(eglDisplay, eglSurface);
|
||||||
|
}
|
||||||
|
if (eglContext != EGL14.EGL_NO_CONTEXT) {
|
||||||
|
EGL14.eglDestroyContext(eglDisplay, eglContext);
|
||||||
|
}
|
||||||
|
EGL14.eglTerminate(eglDisplay);
|
||||||
|
}
|
||||||
|
eglDisplay = EGL14.EGL_NO_DISPLAY;
|
||||||
|
eglContext = EGL14.EGL_NO_CONTEXT;
|
||||||
|
eglSurface = EGL14.EGL_NO_SURFACE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EGLConfig chooseConfig(EGLDisplay display) {
|
||||||
|
int[] attribList = {
|
||||||
|
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||||
|
EGL14.EGL_RED_SIZE, 8,
|
||||||
|
EGL14.EGL_GREEN_SIZE, 8,
|
||||||
|
EGL14.EGL_BLUE_SIZE, 8,
|
||||||
|
EGL14.EGL_ALPHA_SIZE, 8,
|
||||||
|
EGL_RECORDABLE_ANDROID, 1,
|
||||||
|
EGL14.EGL_NONE
|
||||||
|
};
|
||||||
|
EGLConfig[] configs = new EGLConfig[16];
|
||||||
|
int[] numConfigs = new int[1];
|
||||||
|
if (!EGL14.eglChooseConfig(display, attribList, 0, configs, 0, configs.length, numConfigs, 0)
|
||||||
|
|| numConfigs[0] <= 0) {
|
||||||
|
throw new IllegalStateException("eglChooseConfig failed (recordable)");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < numConfigs[0]; i++) {
|
||||||
|
int[] recordable = new int[1];
|
||||||
|
if (EGL14.eglGetConfigAttrib(display, configs[i], EGL_RECORDABLE_ANDROID, recordable, 0)
|
||||||
|
&& recordable[0] != 0) {
|
||||||
|
return configs[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int buildProgram(String vertexSource, String fragmentSource) {
|
||||||
|
int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource);
|
||||||
|
int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
|
||||||
|
int program = GLES20.glCreateProgram();
|
||||||
|
GLES20.glAttachShader(program, vs);
|
||||||
|
GLES20.glAttachShader(program, fs);
|
||||||
|
GLES20.glLinkProgram(program);
|
||||||
|
int[] link = new int[1];
|
||||||
|
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, link, 0);
|
||||||
|
if (link[0] == 0) {
|
||||||
|
String err = GLES20.glGetProgramInfoLog(program);
|
||||||
|
GLES20.glDeleteProgram(program);
|
||||||
|
throw new IllegalStateException("Program link failed: " + err);
|
||||||
|
}
|
||||||
|
GLES20.glDeleteShader(vs);
|
||||||
|
GLES20.glDeleteShader(fs);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int compileShader(int type, String source) {
|
||||||
|
int shader = GLES20.glCreateShader(type);
|
||||||
|
GLES20.glShaderSource(shader, source);
|
||||||
|
GLES20.glCompileShader(shader);
|
||||||
|
int[] compiled = new int[1];
|
||||||
|
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0);
|
||||||
|
if (compiled[0] == 0) {
|
||||||
|
String err = GLES20.glGetShaderInfoLog(shader);
|
||||||
|
GLES20.glDeleteShader(shader);
|
||||||
|
throw new IllegalStateException("Shader compile failed: " + err);
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.foxx.androidcast.sender.calibration;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a TV calibration chart (color bars + pluge) with gray reference areas transparent.
|
||||||
|
*/
|
||||||
|
public final class TvCalibrationGenerator {
|
||||||
|
private static final int[] BAR_COLORS = {
|
||||||
|
Color.WHITE, Color.YELLOW, Color.CYAN, Color.GREEN,
|
||||||
|
Color.MAGENTA, Color.RED, Color.BLUE
|
||||||
|
};
|
||||||
|
|
||||||
|
private TvCalibrationGenerator() {}
|
||||||
|
|
||||||
|
public static void drawChart(Canvas canvas, int width, int height, Context context) {
|
||||||
|
if (context == null) {
|
||||||
|
drawProceduralChart(canvas, width, height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
canvas.drawColor(Color.BLACK);
|
||||||
|
Bitmap ref = loadReference(context);
|
||||||
|
if (ref != null) {
|
||||||
|
drawReferenceWithTransparentGray(canvas, ref, width, height);
|
||||||
|
ref.recycle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drawProceduralChart(canvas, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bitmap loadReference(Context context) {
|
||||||
|
try {
|
||||||
|
return BitmapFactory.decodeResource(context.getResources(), R.drawable.calibration_chart_reference);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawReferenceWithTransparentGray(Canvas canvas, Bitmap ref, int w, int h) {
|
||||||
|
Bitmap scaled = Bitmap.createScaledBitmap(ref, w, h, true);
|
||||||
|
Bitmap keyed = scaled.copy(Bitmap.Config.ARGB_8888, true);
|
||||||
|
scaled.recycle();
|
||||||
|
int[] pixels = new int[w * h];
|
||||||
|
keyed.getPixels(pixels, 0, w, 0, 0, w, h);
|
||||||
|
for (int i = 0; i < pixels.length; i++) {
|
||||||
|
int p = pixels[i];
|
||||||
|
int r = (p >> 16) & 0xff;
|
||||||
|
int g = (p >> 8) & 0xff;
|
||||||
|
int b = p & 0xff;
|
||||||
|
if (isGray(r, g, b)) {
|
||||||
|
pixels[i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyed.setPixels(pixels, 0, w, 0, 0, w, h);
|
||||||
|
canvas.drawBitmap(keyed, 0, 0, null);
|
||||||
|
keyed.recycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isGray(int r, int g, int b) {
|
||||||
|
return Math.abs(r - g) < 25 && Math.abs(g - b) < 25 && r > 40 && r < 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawProceduralChart(Canvas canvas, int width, int height) {
|
||||||
|
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
int barH = (int) (height * 0.55f);
|
||||||
|
int barW = width / BAR_COLORS.length;
|
||||||
|
for (int i = 0; i < BAR_COLORS.length; i++) {
|
||||||
|
paint.setColor(BAR_COLORS[i]);
|
||||||
|
canvas.drawRect(i * barW, 0, (i + 1) * barW, barH, paint);
|
||||||
|
}
|
||||||
|
int lowerTop = barH;
|
||||||
|
int cols = 8;
|
||||||
|
int cellW = width / cols;
|
||||||
|
for (int c = 0; c < cols; c++) {
|
||||||
|
int level = 255 - (c * 255 / (cols - 1));
|
||||||
|
if (c == 0 || c == cols - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
paint.setColor(Color.rgb(level, level, level));
|
||||||
|
canvas.drawRect(c * cellW, lowerTop, (c + 1) * cellW, height, paint);
|
||||||
|
}
|
||||||
|
paint.setColor(Color.WHITE);
|
||||||
|
canvas.drawRect(0, lowerTop, cellW, height, paint);
|
||||||
|
paint.setColor(Color.BLACK);
|
||||||
|
canvas.drawRect((cols - 1) * cellW, lowerTop, width, height, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blue/white vertical stripe border with optional VU-driven stripe width (8dp border). */
|
||||||
|
public static void drawAnimatedBorder(Canvas canvas, int width, int height, float density,
|
||||||
|
int scrollPx, float vuLevel, Context context) {
|
||||||
|
drawChart(canvas, width, height, context);
|
||||||
|
drawBorderOverlay(canvas, width, height, density, scrollPx, vuLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draws only the animated border (chart must already be on the canvas / bitmap). */
|
||||||
|
public static void drawBorderOverlay(Canvas canvas, int width, int height, float density,
|
||||||
|
int scrollPx, float vuLevel) {
|
||||||
|
int borderPx = Math.max(2, Math.round(8f * density));
|
||||||
|
int stripe = Math.max(1, Math.round(1f * density));
|
||||||
|
int maxScroll = width + height;
|
||||||
|
int offset = scrollPx % Math.max(1, maxScroll);
|
||||||
|
float vu = Math.max(0f, Math.min(1f, vuLevel));
|
||||||
|
int blueWidth = Math.max(stripe, Math.round(stripe + vu * borderPx * 1.35f));
|
||||||
|
drawStripeFrame(canvas, width, height, borderPx, stripe, blueWidth, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drawStripeFrame(Canvas canvas, int width, int height, int border,
|
||||||
|
int stripe, int blueWidth, int offset) {
|
||||||
|
Paint paint = new Paint();
|
||||||
|
int cycle = stripe + blueWidth;
|
||||||
|
RectF top = new RectF(0, 0, width, border);
|
||||||
|
RectF bottom = new RectF(0, height - border, width, height);
|
||||||
|
RectF left = new RectF(0, 0, border, height);
|
||||||
|
RectF right = new RectF(width - border, 0, width, height);
|
||||||
|
fillStripes(canvas, paint, top, true, offset, cycle, blueWidth);
|
||||||
|
fillStripes(canvas, paint, bottom, true, offset, cycle, blueWidth);
|
||||||
|
fillStripes(canvas, paint, left, false, offset, cycle, blueWidth);
|
||||||
|
fillStripes(canvas, paint, right, false, offset, cycle, blueWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void fillStripes(Canvas canvas, Paint paint, RectF area, boolean horizontal,
|
||||||
|
int offset, int cycle, int blueWidth) {
|
||||||
|
if (horizontal) {
|
||||||
|
int h = Math.max(1, Math.round(area.height()));
|
||||||
|
for (float x = area.left; x < area.right; ) {
|
||||||
|
int pos = (int) (x + area.top + offset);
|
||||||
|
int color = stripeColor(pos, cycle, blueWidth);
|
||||||
|
float next = Math.min(area.right, x + 1f);
|
||||||
|
paint.setColor(color);
|
||||||
|
canvas.drawRect(x, area.top, next, area.top + h, paint);
|
||||||
|
x = next;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int w = Math.max(1, Math.round(area.width()));
|
||||||
|
for (float y = area.top; y < area.bottom; ) {
|
||||||
|
int pos = (int) (area.left + y + offset);
|
||||||
|
int color = stripeColor(pos, cycle, blueWidth);
|
||||||
|
float next = Math.min(area.bottom, y + 1f);
|
||||||
|
paint.setColor(color);
|
||||||
|
canvas.drawRect(area.left, y, area.left + w, next, paint);
|
||||||
|
y = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int stripeColor(int pos, int cycle, int blueWidth) {
|
||||||
|
int m = pos % cycle;
|
||||||
|
return m < blueWidth ? Color.rgb(30, 144, 255) : Color.WHITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.foxx.androidcast.sender.camera;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.SurfaceTexture;
|
||||||
|
import android.hardware.camera2.CameraAccessException;
|
||||||
|
import android.hardware.camera2.CameraCaptureSession;
|
||||||
|
import android.hardware.camera2.CameraCharacteristics;
|
||||||
|
import android.hardware.camera2.CameraDevice;
|
||||||
|
import android.hardware.camera2.CameraManager;
|
||||||
|
import android.hardware.camera2.CaptureRequest;
|
||||||
|
import android.hardware.camera2.params.StreamConfigurationMap;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.util.Size;
|
||||||
|
import android.view.Surface;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
/** Camera2 preview into the video encoder input {@link Surface}. */
|
||||||
|
public final class CameraCastCapture {
|
||||||
|
private static final String TAG = "CameraCastCapture";
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private HandlerThread thread;
|
||||||
|
private Handler handler;
|
||||||
|
private CameraDevice camera;
|
||||||
|
private CameraCaptureSession session;
|
||||||
|
private String cameraId;
|
||||||
|
|
||||||
|
public CameraCastCapture(Context context) {
|
||||||
|
appContext = context.getApplicationContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException {
|
||||||
|
stop();
|
||||||
|
thread = new HandlerThread("CameraCast");
|
||||||
|
thread.start();
|
||||||
|
handler = new Handler(thread.getLooper());
|
||||||
|
CameraManager mgr = appContext.getSystemService(CameraManager.class);
|
||||||
|
cameraId = chooseCameraId(mgr);
|
||||||
|
if (cameraId == null) {
|
||||||
|
throw new CameraAccessException(CameraAccessException.CAMERA_ERROR,
|
||||||
|
"No back camera available");
|
||||||
|
}
|
||||||
|
Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight);
|
||||||
|
Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight());
|
||||||
|
mgr.openCamera(cameraId, new CameraDevice.StateCallback() {
|
||||||
|
@Override
|
||||||
|
public void onOpened(CameraDevice device) {
|
||||||
|
camera = device;
|
||||||
|
try {
|
||||||
|
createSession(device, encoderSurface, preview);
|
||||||
|
} catch (CameraAccessException e) {
|
||||||
|
Log.e(TAG, "Session failed", e);
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisconnected(CameraDevice device) {
|
||||||
|
device.close();
|
||||||
|
camera = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(CameraDevice device, int error) {
|
||||||
|
Log.e(TAG, "Camera error " + error);
|
||||||
|
device.close();
|
||||||
|
camera = null;
|
||||||
|
}
|
||||||
|
}, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createSession(CameraDevice device, Surface encoderSurface, Size preview)
|
||||||
|
throws CameraAccessException {
|
||||||
|
CaptureRequest.Builder builder = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
|
||||||
|
builder.addTarget(encoderSurface);
|
||||||
|
device.createCaptureSession(Collections.singletonList(encoderSurface),
|
||||||
|
new CameraCaptureSession.StateCallback() {
|
||||||
|
@Override
|
||||||
|
public void onConfigured(CameraCaptureSession captureSession) {
|
||||||
|
session = captureSession;
|
||||||
|
try {
|
||||||
|
builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO);
|
||||||
|
captureSession.setRepeatingRequest(builder.build(), null, handler);
|
||||||
|
Log.i(TAG, "Camera preview running");
|
||||||
|
} catch (CameraAccessException e) {
|
||||||
|
Log.e(TAG, "Repeating request failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onConfigureFailed(CameraCaptureSession captureSession) {
|
||||||
|
Log.e(TAG, "Camera session configure failed");
|
||||||
|
}
|
||||||
|
}, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String chooseCameraId(CameraManager mgr) throws CameraAccessException {
|
||||||
|
for (String id : mgr.getCameraIdList()) {
|
||||||
|
CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
|
||||||
|
Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
|
||||||
|
if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String[] ids = mgr.getCameraIdList();
|
||||||
|
return ids.length > 0 ? ids[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Size choosePreviewSize(CameraManager mgr, String id, int targetW, int targetH)
|
||||||
|
throws CameraAccessException {
|
||||||
|
CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
|
||||||
|
StreamConfigurationMap map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
|
||||||
|
if (map == null) {
|
||||||
|
return new Size(targetW, targetH);
|
||||||
|
}
|
||||||
|
Size[] sizes = map.getOutputSizes(SurfaceTexture.class);
|
||||||
|
if (sizes == null || sizes.length == 0) {
|
||||||
|
return new Size(targetW, targetH);
|
||||||
|
}
|
||||||
|
Size best = sizes[0];
|
||||||
|
long bestScore = Long.MAX_VALUE;
|
||||||
|
for (Size s : sizes) {
|
||||||
|
long score = Math.abs((long) s.getWidth() * s.getHeight() - (long) targetW * targetH);
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (session != null) {
|
||||||
|
try {
|
||||||
|
session.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
if (camera != null) {
|
||||||
|
try {
|
||||||
|
camera.close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
camera = null;
|
||||||
|
}
|
||||||
|
if (thread != null) {
|
||||||
|
thread.quitSafely();
|
||||||
|
thread = null;
|
||||||
|
}
|
||||||
|
handler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package com.foxx.androidcast.stats;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastSettings;
|
||||||
|
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||||
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}.
|
||||||
|
*/
|
||||||
|
public final class SessionStatsRecorder {
|
||||||
|
private final long startedMs = System.currentTimeMillis();
|
||||||
|
private final String role;
|
||||||
|
private final String transport;
|
||||||
|
private String videoCodec = "";
|
||||||
|
private String audioCodec = "AAC";
|
||||||
|
private String captureMode = "";
|
||||||
|
private String resolution = "";
|
||||||
|
private int clientCount = 1;
|
||||||
|
private int disruptCount;
|
||||||
|
private int idleTimeouts;
|
||||||
|
private int networkSamples;
|
||||||
|
|
||||||
|
private long sumRttMs;
|
||||||
|
private long sumLossRatioMicro; // loss * 1e6
|
||||||
|
private long sumVideoBitrateKbps;
|
||||||
|
private long sumAudioBitrateKbps;
|
||||||
|
private long sumEncodeFpsMicro;
|
||||||
|
private long sumDecodeFpsMicro;
|
||||||
|
private int bitrateSamples;
|
||||||
|
private int fpsSamples;
|
||||||
|
|
||||||
|
private final TransportLossStats lossTotals = new TransportLossStats();
|
||||||
|
|
||||||
|
public SessionStatsRecorder(String role, String transport) {
|
||||||
|
this.role = role;
|
||||||
|
this.transport = transport != null ? transport : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setClientCount(int count) {
|
||||||
|
if (count > 0) {
|
||||||
|
clientCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCodecs(String videoMime, String audioMime) {
|
||||||
|
if (videoMime != null) {
|
||||||
|
videoCodec = videoMime;
|
||||||
|
}
|
||||||
|
if (audioMime != null) {
|
||||||
|
audioCodec = audioMime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCaptureMode(String mode) {
|
||||||
|
captureMode = mode != null ? mode : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResolution(int w, int h) {
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
resolution = w + "x" + h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void noteDisrupt(String reason) {
|
||||||
|
disruptCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void noteIdleTimeout() {
|
||||||
|
idleTimeouts++;
|
||||||
|
disruptCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) {
|
||||||
|
networkSamples++;
|
||||||
|
NetworkStatsSnapshot s = local != null ? local : peer;
|
||||||
|
if (s == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s.rttMs > 0) {
|
||||||
|
sumRttMs += s.rttMs;
|
||||||
|
}
|
||||||
|
if (s.lossRatio > 0) {
|
||||||
|
sumLossRatioMicro += (long) (s.lossRatio * 1_000_000);
|
||||||
|
}
|
||||||
|
if (s.sendBitrateKbps > 0) {
|
||||||
|
sumVideoBitrateKbps += s.sendBitrateKbps;
|
||||||
|
bitrateSamples++;
|
||||||
|
} else if (s.recvBitrateKbps > 0) {
|
||||||
|
sumVideoBitrateKbps += s.recvBitrateKbps;
|
||||||
|
bitrateSamples++;
|
||||||
|
}
|
||||||
|
if (s.encodeFps > 0) {
|
||||||
|
sumEncodeFpsMicro += (long) (s.encodeFps * 1000);
|
||||||
|
fpsSamples++;
|
||||||
|
}
|
||||||
|
if (s.targetBitrateKbps > 0) {
|
||||||
|
sumAudioBitrateKbps += Math.min(s.targetBitrateKbps, 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Final transport/app loss snapshot for this session (not accumulated over time). */
|
||||||
|
public void mergeLoss(TransportLossStats loss) {
|
||||||
|
if (loss == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (lossTotals) {
|
||||||
|
lossTotals.datagramsSent = loss.datagramsSent;
|
||||||
|
lossTotals.datagramsReceived = loss.datagramsReceived;
|
||||||
|
lossTotals.datagramsInvalid = loss.datagramsInvalid;
|
||||||
|
lossTotals.fragmentsReceived = loss.fragmentsReceived;
|
||||||
|
lossTotals.fragmentsDuplicate = loss.fragmentsDuplicate;
|
||||||
|
lossTotals.reassemblyCompleted = loss.reassemblyCompleted;
|
||||||
|
lossTotals.reassemblyIncomplete = loss.reassemblyIncomplete;
|
||||||
|
lossTotals.reassemblySuperseded = loss.reassemblySuperseded;
|
||||||
|
lossTotals.wirePacketsDropped = loss.wirePacketsDropped;
|
||||||
|
lossTotals.malformedPackets = loss.malformedPackets;
|
||||||
|
lossTotals.sendDroppedVideoP = loss.sendDroppedVideoP;
|
||||||
|
lossTotals.sendDroppedAudio = loss.sendDroppedAudio;
|
||||||
|
lossTotals.recvVideoSkippedAwaitingKey = loss.recvVideoSkippedAwaitingKey;
|
||||||
|
lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors;
|
||||||
|
lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors;
|
||||||
|
lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops;
|
||||||
|
lossTotals.protectionMode = loss.protectionMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void mergeSettings(CastSettings settings) {
|
||||||
|
if (settings == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captureMode = settings.getCaptureMode() != null ? settings.getCaptureMode().name() : captureMode;
|
||||||
|
if (settings.getNegotiatedVideoMime() != null) {
|
||||||
|
videoCodec = settings.getNegotiatedVideoMime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finish(Context context) {
|
||||||
|
long endedMs = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
JSONObject root = new JSONObject();
|
||||||
|
root.put("schema_version", 1);
|
||||||
|
root.put("started_at_epoch_ms", startedMs);
|
||||||
|
root.put("ended_at_epoch_ms", endedMs);
|
||||||
|
root.put("duration_ms", endedMs - startedMs);
|
||||||
|
root.put("role", role);
|
||||||
|
root.put("transport", transport);
|
||||||
|
root.put("client_count", clientCount);
|
||||||
|
root.put("disrupt_count", disruptCount);
|
||||||
|
root.put("idle_timeout_count", idleTimeouts);
|
||||||
|
root.put("video_codec", videoCodec);
|
||||||
|
root.put("audio_codec", audioCodec);
|
||||||
|
root.put("capture_mode", captureMode);
|
||||||
|
root.put("resolution", resolution);
|
||||||
|
root.put("network_samples", networkSamples);
|
||||||
|
|
||||||
|
if (networkSamples > 0) {
|
||||||
|
root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples));
|
||||||
|
root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples)));
|
||||||
|
}
|
||||||
|
if (bitrateSamples > 0) {
|
||||||
|
root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples);
|
||||||
|
}
|
||||||
|
if (fpsSamples > 0) {
|
||||||
|
root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples));
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject protection = new JSONObject();
|
||||||
|
protection.put("mode", lossTotals.protectionMode.name());
|
||||||
|
protection.put("fec", "stub_disabled");
|
||||||
|
protection.put("nack", "stub_disabled");
|
||||||
|
protection.put("fec_stub_packets", lossTotals.fecPacketsStub);
|
||||||
|
protection.put("nack_stub_requests", lossTotals.nackRequestsStub);
|
||||||
|
root.put("stream_protection", protection);
|
||||||
|
|
||||||
|
JSONObject udp = new JSONObject();
|
||||||
|
synchronized (lossTotals) {
|
||||||
|
udp.put("datagrams_tx", lossTotals.datagramsSent);
|
||||||
|
udp.put("datagrams_rx", lossTotals.datagramsReceived);
|
||||||
|
udp.put("datagrams_invalid", lossTotals.datagramsInvalid);
|
||||||
|
udp.put("fragments_rx", lossTotals.fragmentsReceived);
|
||||||
|
udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate);
|
||||||
|
udp.put("reassembly_ok", lossTotals.reassemblyCompleted);
|
||||||
|
udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded);
|
||||||
|
udp.put("wire_dropped", lossTotals.wirePacketsDropped);
|
||||||
|
udp.put("malformed", lossTotals.malformedPackets);
|
||||||
|
udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP);
|
||||||
|
udp.put("send_dropped_audio", lossTotals.sendDroppedAudio);
|
||||||
|
udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey);
|
||||||
|
udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors);
|
||||||
|
udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors);
|
||||||
|
udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops);
|
||||||
|
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
||||||
|
}
|
||||||
|
root.put("udp", udp);
|
||||||
|
|
||||||
|
SessionStatsStore.saveSession(context, startedMs, root);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.foxx.androidcast.stats;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** Rotating flat JSON session logs in app-private storage. */
|
||||||
|
public final class SessionStatsStore {
|
||||||
|
private static final String DIR_NAME = "session_stats";
|
||||||
|
private static final String PREFIX = "session_";
|
||||||
|
private static final String SUFFIX = ".json";
|
||||||
|
|
||||||
|
private SessionStatsStore() {}
|
||||||
|
|
||||||
|
public static File statsDir(Context context) {
|
||||||
|
return new File(context.getApplicationContext().getFilesDir(), DIR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */
|
||||||
|
public static void pruneOnAppStart(Context context) {
|
||||||
|
pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes oldest session JSON files until count is below {@code maxFiles}. */
|
||||||
|
static void pruneDirectory(File dir, int maxFiles) {
|
||||||
|
if (dir == null || !dir.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX));
|
||||||
|
if (files == null || files.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<File> list = new ArrayList<>();
|
||||||
|
Collections.addAll(list, files);
|
||||||
|
Collections.sort(list, Comparator.comparing(File::getName));
|
||||||
|
while (list.size() >= maxFiles) {
|
||||||
|
File oldest = list.remove(0);
|
||||||
|
if (!oldest.delete()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception {
|
||||||
|
File dir = statsDir(context);
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
throw new IllegalStateException("Cannot create session_stats dir");
|
||||||
|
}
|
||||||
|
String name = PREFIX + formatTimestamp(startedMs) + SUFFIX;
|
||||||
|
File out = new File(dir, name);
|
||||||
|
byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8);
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(out)) {
|
||||||
|
fos.write(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatTimestamp(long epochMs) {
|
||||||
|
return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,12 +29,19 @@ public class AspectRatioFrameLayout extends FrameLayout {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
|
||||||
if (aspectRatio <= 0f || getChildCount() == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int parentW = MeasureSpec.getSize(widthMeasureSpec);
|
int parentW = MeasureSpec.getSize(widthMeasureSpec);
|
||||||
int parentH = MeasureSpec.getSize(heightMeasureSpec);
|
int parentH = MeasureSpec.getSize(heightMeasureSpec);
|
||||||
|
if (getChildCount() == 0) {
|
||||||
|
setMeasuredDimension(parentW, parentH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aspectRatio <= 0f) {
|
||||||
|
int fullW = MeasureSpec.makeMeasureSpec(parentW, MeasureSpec.EXACTLY);
|
||||||
|
int fullH = MeasureSpec.makeMeasureSpec(parentH, MeasureSpec.EXACTLY);
|
||||||
|
measureChild(getChildAt(0), fullW, fullH);
|
||||||
|
setMeasuredDimension(parentW, parentH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
int childW;
|
int childW;
|
||||||
int childH;
|
int childH;
|
||||||
float parentRatio = (float) parentW / parentH;
|
float parentRatio = (float) parentW / parentH;
|
||||||
@@ -56,6 +63,10 @@ public class AspectRatioFrameLayout extends FrameLayout {
|
|||||||
if (getChildCount() == 0) {
|
if (getChildCount() == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (aspectRatio <= 0f) {
|
||||||
|
getChildAt(0).layout(left, top, right, bottom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
int parentW = right - left;
|
int parentW = right - left;
|
||||||
int parentH = bottom - top;
|
int parentH = bottom - top;
|
||||||
int childW = getChildAt(0).getMeasuredWidth();
|
int childW = getChildAt(0).getMeasuredWidth();
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.foxx.androidcast.ui;
|
||||||
|
|
||||||
|
import android.graphics.Matrix;
|
||||||
|
import android.view.TextureView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letterboxes decoded video in a {@link TextureView}.
|
||||||
|
* Uses view-space scale around center (same approach as ExoPlayer), not pixel {@link Matrix#setRectToRect}.
|
||||||
|
*/
|
||||||
|
public final class PlaybackTextureTransform {
|
||||||
|
private PlaybackTextureTransform() {}
|
||||||
|
|
||||||
|
public static void applyFitCenter(TextureView view, int videoWidth, int videoHeight) {
|
||||||
|
if (view == null || videoWidth <= 0 || videoHeight <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int viewW = view.getWidth();
|
||||||
|
int viewH = view.getHeight();
|
||||||
|
if (viewW <= 0 || viewH <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float videoAspect = (float) videoWidth / videoHeight;
|
||||||
|
float viewAspect = (float) viewW / viewH;
|
||||||
|
float scaleX = 1f;
|
||||||
|
float scaleY = 1f;
|
||||||
|
if (videoAspect > viewAspect) {
|
||||||
|
scaleY = viewAspect / videoAspect;
|
||||||
|
} else {
|
||||||
|
scaleX = videoAspect / viewAspect;
|
||||||
|
}
|
||||||
|
Matrix matrix = new Matrix();
|
||||||
|
matrix.setScale(scaleX, scaleY, viewW / 2f, viewH / 2f);
|
||||||
|
view.setTransform(matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clearTransform(TextureView view) {
|
||||||
|
if (view != null) {
|
||||||
|
view.setTransform(new Matrix());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/src/main/res/drawable-nodpi/calibration_chart_reference.png
Normal file
BIN
app/src/main/res/drawable-nodpi/calibration_chart_reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
@@ -16,7 +16,7 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="center">
|
android:layout_gravity="center">
|
||||||
|
|
||||||
<SurfaceView
|
<TextureView
|
||||||
android:id="@+id/surface_playback"
|
android:id="@+id/surface_playback"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
<string name="app_name">Android Cast</string>
|
<string name="app_name">Android Cast</string>
|
||||||
<string name="send_screen">Send screen (tablet / phone)</string>
|
<string name="send_screen">Send screen (tablet / phone)</string>
|
||||||
<string name="receive_screen">Receive on TV / projector</string>
|
<string name="receive_screen">Receive on TV / projector</string>
|
||||||
<string name="main_hint">Same Wi‑Fi required. Default PIN: 1234. Match transport (TCP/UDP) on both devices.</string>
|
<string name="main_hint">Same Wi‑Fi required. Default PIN: 1234. UDP is default; pick TCP in settings if needed. Match transport on both devices.</string>
|
||||||
<string name="sender_title">Cast to receiver</string>
|
<string name="sender_title">Cast to receiver</string>
|
||||||
<string name="receiver_title">Waiting for cast</string>
|
<string name="receiver_title">Waiting for cast</string>
|
||||||
<string name="pin_hint">PIN</string>
|
<string name="pin_hint">PIN</string>
|
||||||
<string name="refresh">Refresh</string>
|
<string name="refresh">REFRESH</string>
|
||||||
|
<string name="refresh_countdown">REFRESH (%1$d)</string>
|
||||||
<string name="start_cast">Start cast</string>
|
<string name="start_cast">Start cast</string>
|
||||||
<string name="searching">Searching for receivers…</string>
|
<string name="searching">Searching for receivers…</string>
|
||||||
<string name="waiting_sender">Waiting for sender…</string>
|
<string name="waiting_sender">Waiting for sender…</string>
|
||||||
@@ -25,8 +26,16 @@
|
|||||||
<string name="audio_unavailable_title">Video only</string>
|
<string name="audio_unavailable_title">Video only</string>
|
||||||
<string name="audio_unavailable_message">This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only.</string>
|
<string name="audio_unavailable_message">This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only.</string>
|
||||||
<string name="continue_video_only">Continue (video only)</string>
|
<string name="continue_video_only">Continue (video only)</string>
|
||||||
<string name="transport_tcp">TCP (reliable)</string>
|
<string name="transport_udp">UDP (default, best effort)</string>
|
||||||
<string name="transport_udp">UDP (best effort)</string>
|
<string name="transport_tcp">TCP (reliable fallback)</string>
|
||||||
|
<string name="transport_quic">QUIC experimental (Cronet)</string>
|
||||||
|
<string name="transport_webrtc">WebRTC (stub, future)</string>
|
||||||
|
<string name="capture_calibration_test">Calibration test pattern</string>
|
||||||
|
<string name="capture_camera">Camera (rear Camera2)</string>
|
||||||
|
<string name="capture_camera_unavailable">Camera source (requires Android 10+)</string>
|
||||||
|
<string name="capture_camera_not_implemented">Camera capture is not implemented yet. Use screen or calibration test.</string>
|
||||||
|
<string name="casting_calibration">Calibration cast %1$dx%2$d</string>
|
||||||
|
<string name="casting_camera">Camera cast %1$dx%2$d</string>
|
||||||
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
|
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
|
||||||
<string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string>
|
<string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string>
|
||||||
<string name="quality_high">High (~10 Mbps, 30 fps)</string>
|
<string name="quality_high">High (~10 Mbps, 30 fps)</string>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.foxx.androidcast;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class CastResolutionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void calibrationTestSizeIsLandscape16x9() {
|
||||||
|
CastResolution.Size size = CastResolution.calibrationTestSize();
|
||||||
|
assertEquals(960, size.width);
|
||||||
|
assertEquals(540, size.height);
|
||||||
|
assertTrue(size.width > size.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import android.media.MediaFormat;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class EncodedStreamProbeTest {
|
||||||
|
@Test
|
||||||
|
public void hevcKeyframeReportsNalType() {
|
||||||
|
byte[] vps = {0, 0, 0, 1, 0x40, 0x01, 0x0c, 0x01};
|
||||||
|
String desc = EncodedStreamProbe.describeKeyframe(MediaFormat.MIMETYPE_VIDEO_HEVC, vps);
|
||||||
|
assertTrue(desc.contains("HEVC"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class H264BitstreamHevcTest {
|
||||||
|
@Test
|
||||||
|
public void avccHevcNalGetsStartCode() {
|
||||||
|
byte[] vps = {0, 0, 0, 4, 0x40, 0x01, 0x0c, 0x01};
|
||||||
|
byte[] annexB = H264Bitstream.toAnnexB(vps);
|
||||||
|
assertTrue(annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1);
|
||||||
|
assertEquals(0x40, annexB[4] & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class RgbToNv21Test {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whitePixelProducesHighLuma() {
|
||||||
|
int[] argb = {0xFFFFFFFF, 0, 0, 0xFFFFFFFF};
|
||||||
|
byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2);
|
||||||
|
int y = nv21[0] & 0xff;
|
||||||
|
assertTrue(y > 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void blackPixelProducesLowLuma() {
|
||||||
|
int[] argb = {0xFF000000, 0, 0, 0xFF000000};
|
||||||
|
byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2);
|
||||||
|
int y = nv21[0] & 0xff;
|
||||||
|
assertEquals(16, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bufferSizeMatchesNv21Layout() {
|
||||||
|
int w = 16;
|
||||||
|
int h = 16;
|
||||||
|
int[] argb = new int[w * h];
|
||||||
|
byte[] nv21 = RgbToNv21.fromArgb(argb, w, h);
|
||||||
|
assertEquals(w * h + w * h / 2, nv21.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.foxx.androidcast.media;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertNotEquals;
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
public class RgbToYuv420Test {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void redPixelDiffersBetweenYAndChromaPlanes() {
|
||||||
|
int[] argb = {0xFFFF0000, 0, 0, 0xFFFF0000};
|
||||||
|
byte[] i420 = RgbToYuv420.fromArgb(argb, 2, 2, RgbToYuv420.Layout.I420);
|
||||||
|
int y = i420[0] & 0xff;
|
||||||
|
int u = i420[4] & 0xff;
|
||||||
|
int v = i420[5] & 0xff;
|
||||||
|
assertNotEquals(y, u);
|
||||||
|
assertNotEquals(y, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bufferSizeMatchesI420Layout() {
|
||||||
|
assertEquals(960 * 540 + (960 * 540) / 2, RgbToYuv420.bufferSize(960, 540));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertArrayEquals;
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class CastProtoHeaderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void encodeDecodeRoundTrip() throws Exception {
|
||||||
|
CastProtoHeader original = CastProtoHeader.forCast(0x12345678, 0xABCDEF01, 0x0F, 0xF0);
|
||||||
|
byte[] wire = original.encode();
|
||||||
|
CastProtoHeader decoded = CastProtoHeader.decode(wire, 0, wire.length);
|
||||||
|
assertEquals(original.sessionId, decoded.sessionId);
|
||||||
|
assertEquals(original.placeholderId, decoded.placeholderId);
|
||||||
|
assertEquals(original.videoCodecs, decoded.videoCodecs);
|
||||||
|
assertEquals(original.audioCodecs, decoded.audioCodecs);
|
||||||
|
assertTrue(decoded.verifyChecksum());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void frameUnframePreservesPayload() throws Exception {
|
||||||
|
CastProtoHeader hdr = CastProtoHeader.forCast(99, 1, 2, 3);
|
||||||
|
byte[] payload = new byte[] {1, 2, 3, 4};
|
||||||
|
byte[] wire = CastPacketFramer.frame(hdr, (byte) 7, payload);
|
||||||
|
CastProtoHeader fromWire = CastPacketFramer.readHeader(wire);
|
||||||
|
assertEquals(99, fromWire.sessionId);
|
||||||
|
CastProtocol.Message msg = CastPacketFramer.unframe(wire);
|
||||||
|
assertEquals(7, msg.type);
|
||||||
|
assertArrayEquals(payload, msg.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.foxx.androidcast.network;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class CastSessionGateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bindsFirstNonZeroSession() {
|
||||||
|
CastSessionGate gate = new CastSessionGate();
|
||||||
|
CastProtoHeader first = new CastProtoHeader();
|
||||||
|
first.sessionId = 42;
|
||||||
|
assertTrue(gate.accept(first));
|
||||||
|
assertEquals(42, gate.getBoundSessionId());
|
||||||
|
|
||||||
|
CastProtoHeader stale = new CastProtoHeader();
|
||||||
|
stale.sessionId = 99;
|
||||||
|
assertFalse(gate.accept(stale));
|
||||||
|
|
||||||
|
CastProtoHeader same = new CastProtoHeader();
|
||||||
|
same.sessionId = 42;
|
||||||
|
assertTrue(gate.accept(same));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void zeroSessionAlwaysAccepted() {
|
||||||
|
CastSessionGate gate = new CastSessionGate();
|
||||||
|
CastProtoHeader zero = new CastProtoHeader();
|
||||||
|
zero.sessionId = 0;
|
||||||
|
assertTrue(gate.accept(zero));
|
||||||
|
assertFalse(gate.isBound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resetAllowsRebind() {
|
||||||
|
CastSessionGate gate = new CastSessionGate();
|
||||||
|
CastProtoHeader a = new CastProtoHeader();
|
||||||
|
a.sessionId = 1;
|
||||||
|
gate.accept(a);
|
||||||
|
gate.reset();
|
||||||
|
CastProtoHeader b = new CastProtoHeader();
|
||||||
|
b.sessionId = 2;
|
||||||
|
assertTrue(gate.accept(b));
|
||||||
|
assertEquals(2, gate.getBoundSessionId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.foxx.androidcast.stats;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public class SessionStatsStoreTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void pruneDirectory_keepsAtMostMaxMinusOne() throws IOException {
|
||||||
|
File dir = Files.createTempDirectory("session_stats_test").toFile();
|
||||||
|
int max = CastConfig.SESSION_STATS_MAX_FILES;
|
||||||
|
for (int i = 0; i < max + 5; i++) {
|
||||||
|
File f = new File(dir, String.format("session_20260101_%06d.json", i));
|
||||||
|
assertTrue(f.createNewFile());
|
||||||
|
Files.write(f.toPath(), "{}".getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
assertEquals(max + 5, dir.list((d, n) -> n.endsWith(".json")).length);
|
||||||
|
|
||||||
|
SessionStatsStore.pruneDirectory(dir, max);
|
||||||
|
|
||||||
|
String[] remaining = dir.list((d, n) -> n.endsWith(".json"));
|
||||||
|
Arrays.sort(remaining);
|
||||||
|
assertEquals(max - 1, remaining.length);
|
||||||
|
assertEquals("session_20260101_000006.json", remaining[0]);
|
||||||
|
assertEquals("session_20260101_000024.json", remaining[remaining.length - 1]);
|
||||||
|
|
||||||
|
for (File f : dir.listFiles()) {
|
||||||
|
assertTrue(f.delete());
|
||||||
|
}
|
||||||
|
assertTrue(dir.delete());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.foxx.androidcast.ui;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class PlaybackTextureTransformTest {
|
||||||
|
@Test
|
||||||
|
public void wideVideoOnWideViewIsUnityScale() {
|
||||||
|
float[] scales = scalesFor(1920, 1080, 960, 540);
|
||||||
|
assertEquals(1f, scales[0], 0.01f);
|
||||||
|
assertEquals(1f, scales[1], 0.01f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void tallVideoOnWideViewLetterboxesHorizontally() {
|
||||||
|
float[] scales = scalesFor(1920, 1080, 540, 960);
|
||||||
|
assertEquals(0.316f, scales[0], 0.02f);
|
||||||
|
assertEquals(1f, scales[1], 0.01f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] scalesFor(int viewW, int viewH, int videoW, int videoH) {
|
||||||
|
float videoAspect = (float) videoW / videoH;
|
||||||
|
float viewAspect = (float) viewW / viewH;
|
||||||
|
float scaleX = 1f;
|
||||||
|
float scaleY = 1f;
|
||||||
|
if (videoAspect > viewAspect) {
|
||||||
|
scaleY = viewAspect / videoAspect;
|
||||||
|
} else {
|
||||||
|
scaleX = videoAspect / viewAspect;
|
||||||
|
}
|
||||||
|
return new float[] {scaleX, scaleY};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user