1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +03:00
Signed-off-by: Anton Afanasyeu <a.afanasieff@gmail.com>
This commit is contained in:
Anton Afanasyeu
2026-05-18 17:46:19 +02:00
parent f3bcc31e92
commit 8f991ad79b
16 changed files with 538 additions and 23 deletions

View File

@@ -20,6 +20,8 @@ public final class AppPreferences {
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
private static final String KEY_DEV_RECEIVER_DEBUG_OVERLAY = "dev_receiver_debug_overlay";
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined";
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled";
private static final String PREFIX_SENDER = "sender_";
private static final String PREFIX_RECEIVER = "receiver_";
@@ -112,6 +114,38 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_DEV_GRAB_SESSION_STATS, grab).apply();
}
/**
* Receiver PiP: user toggle when set; otherwise derived from PiP support + notifications permission.
*/
public static boolean isReceiverPipEnabled(Context context) {
if (prefs(context).getBoolean(KEY_RECEIVER_PIP_USER_DEFINED, false)) {
return prefs(context).getBoolean(KEY_RECEIVER_PIP_ENABLED, false);
}
return defaultReceiverPipEnabled(context);
}
/** Value shown in settings before the user changes the toggle. */
public static boolean getReceiverPipEffective(Context context) {
return isReceiverPipEnabled(context);
}
public static boolean isReceiverPipUserDefined(Context context) {
return prefs(context).getBoolean(KEY_RECEIVER_PIP_USER_DEFINED, false);
}
public static void setReceiverPipUserChoice(Context context, boolean enabled) {
prefs(context).edit()
.putBoolean(KEY_RECEIVER_PIP_USER_DEFINED, true)
.putBoolean(KEY_RECEIVER_PIP_ENABLED, enabled)
.apply();
}
private static boolean defaultReceiverPipEnabled(Context context) {
return PictureInPictureHelper.isSupported()
&& PictureInPictureHelper.isAllowedForApp(context)
&& PermissionHelper.hasReceiverPlaybackReady(context);
}
public static CastSettings loadSenderDefaults(Context context) {
CastSettings s = new CastSettings();
applyStoredCastSettings(context, PREFIX_SENDER, s);

View File

@@ -5,10 +5,16 @@ public final class CastConfig {
public static final int DISCOVERY_PORT = 41234;
/** One UDP browse pass duration (sender shows countdown on Refresh). */
public static final long DISCOVERY_SCAN_PASS_MS = 30_000;
/** Fast scan when user taps Refresh (UDP burst + NSD). */
public static final long DISCOVERY_BURST_MS = 5_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;
/** Probe interval during burst scan. */
public static final long DISCOVERY_BURST_PROBE_MS = 250;
/** NSD service type for LAN discovery (in addition to UDP). */
public static final String NSD_SERVICE_TYPE = "_androidcast._udp.";
public static final int CAST_PORT = 41235;
public static final String DISCOVERY_MAGIC = "ACAST1";

View File

@@ -103,6 +103,7 @@ public final class CastSettingsBinder {
}
public static void bindReceiver(AppCompatActivity activity, CastSettings settings) {
bindReceiverPip(activity);
bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
@@ -129,6 +130,7 @@ public final class CastSettingsBinder {
}
public static void saveReceiver(AppCompatActivity activity, CastSettings settings) {
saveReceiverPip(activity);
readTransport(activity, settings, R.id.spinner_receiver_transport);
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
@@ -312,6 +314,37 @@ public final class CastSettingsBinder {
readEnumSpinner(activity.findViewById(R.id.spinner_sender_resolution), settings::setResolution);
}
public static void bindReceiverPip(AppCompatActivity activity) {
CheckBox pip = activity.findViewById(R.id.check_receiver_pip);
if (pip == null) {
return;
}
boolean supported = PictureInPictureHelper.isSupported();
pip.setEnabled(supported);
pip.setOnCheckedChangeListener(null);
pip.setChecked(AppPreferences.getReceiverPipEffective(activity));
pip.setOnCheckedChangeListener((btn, checked) ->
AppPreferences.setReceiverPipUserChoice(activity, checked));
if (!supported) {
pip.setChecked(false);
}
}
/** Re-apply permission-derived default when the user has not chosen explicitly. */
public static void refreshReceiverPipFromPermissions(AppCompatActivity activity) {
if (AppPreferences.isReceiverPipUserDefined(activity)) {
return;
}
bindReceiverPip(activity);
}
private static void saveReceiverPip(AppCompatActivity activity) {
CheckBox pip = activity.findViewById(R.id.check_receiver_pip);
if (pip != null && pip.isEnabled()) {
AppPreferences.setReceiverPipUserChoice(activity, pip.isChecked());
}
}
private static void bindReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution);
CastSettings.ReceiverDisplayResolution[] modes = CastSettings.ReceiverDisplayResolution.values();

View File

@@ -119,6 +119,12 @@ public class CastSettingsFragment extends Fragment {
});
}
@Override
public void onResume() {
super.onResume();
CastSettingsBinder.refreshReceiverPipFromPermissions((AppCompatActivity) requireActivity());
}
@Override
public void onPause() {
saveAll();

View File

@@ -123,8 +123,8 @@ public final class PermissionHelper {
}
/** Permissions needed before receiver cast / PiP (notifications for foreground service). */
public static boolean hasReceiverPlaybackReady(Activity activity) {
return missingForReceiverPlayback(activity).isEmpty();
public static boolean hasReceiverPlaybackReady(android.content.Context context) {
return missingForReceiverPlayback(context).isEmpty();
}
public static void remindForReceiverPlayback(Activity activity) {
@@ -148,13 +148,15 @@ public final class PermissionHelper {
.show();
}
private static List<PermissionItem> missingForReceiverPlayback(Activity activity) {
private static List<PermissionItem> missingForReceiverPlayback(android.content.Context context) {
List<PermissionItem> needed = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS,
activity.getString(R.string.permission_notifications_cast), true));
String label = context instanceof Activity
? ((Activity) context).getString(R.string.permission_notifications_cast)
: "Notifications (cast status)";
needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS, label, true));
}
}
return needed;

View File

@@ -56,6 +56,8 @@ public class DiscoveryManager {
public final long protoVersion;
/** SHA-256 hex of receiver PIN, empty if anonymous. */
public final String pinAuth;
/** Shown from {@link PinnedReceiversStore} at top of sender list. */
public boolean pinnedRecent;
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "");
@@ -96,6 +98,8 @@ public class DiscoveryManager {
private final Object lockGuard = new Object();
private WifiManager.MulticastLock multicastLock;
private int multicastHolds;
private boolean burstScan;
private NsdCastDiscovery nsdDiscovery;
public DiscoveryManager(Context context) {
this.appContext = context.getApplicationContext();
@@ -110,11 +114,18 @@ public class DiscoveryManager {
}
public void startBrowsing() {
startBrowsing(false);
}
/** @param burst shorter UDP pass + NSD (user Refresh). */
public void startBrowsing(boolean burst) {
if (!running.compareAndSet(false, true)) {
return;
}
burstScan = burst;
purgeSelfFromDeviceMap();
notifyScanStarted();
startNsdBrowse();
executor.execute(this::browseLoop);
}
@@ -124,6 +135,8 @@ public class DiscoveryManager {
*/
public void stop() {
running.set(false);
stopNsdBrowse();
stopNsdRegister();
DatagramSocket toClose = socket;
socket = null;
if (toClose != null) {
@@ -154,9 +167,25 @@ public class DiscoveryManager {
if (!running.compareAndSet(false, true)) {
return;
}
String pinHash = com.foxx.androidcast.PinAuth.isProtected(pin)
? com.foxx.androidcast.PinAuth.hash(pin) : "";
startNsdRegister(deviceName, port, transport, pinHash);
executor.execute(() -> announceLoop(context, deviceName, port, transport, listening, pin));
}
/** Merge device from NSD or pinned list into the browse map. */
public void upsertExternal(DiscoveredDevice device) {
if (device == null || device.host == null || device.host.isEmpty()) {
return;
}
if (DiscoverySelfFilter.isSelf(appContext, device.host, null)) {
return;
}
String key = device.host + ":" + device.port;
devices.put(key, device);
notifyListener();
}
public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
startAnnouncing(appContext, deviceName, port, transport, listening, "");
}
@@ -170,13 +199,16 @@ public class DiscoveryManager {
sock.setBroadcast(true);
sock.setSoTimeout(300);
byte[] buf = new byte[1024];
long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS;
long passMs = burstScan ? CastConfig.DISCOVERY_BURST_MS : CastConfig.DISCOVERY_SCAN_PASS_MS;
long probeInterval = burstScan ? CastConfig.DISCOVERY_BURST_PROBE_MS
: CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
long deadline = System.currentTimeMillis() + passMs;
long nextProbeMs = System.currentTimeMillis();
while (running.get() && System.currentTimeMillis() < deadline) {
long now = System.currentTimeMillis();
if (now >= nextProbeMs) {
sendDiscoveryProbe(sock);
nextProbeMs = now + CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
nextProbeMs = now + probeInterval;
}
DatagramPacket packet = new DatagramPacket(buf, buf.length);
try {
@@ -451,6 +483,32 @@ public class DiscoveryManager {
}
}
private void startNsdBrowse() {
if (nsdDiscovery == null) {
nsdDiscovery = new NsdCastDiscovery(appContext, this::upsertExternal);
}
nsdDiscovery.startDiscovering();
}
private void stopNsdBrowse() {
if (nsdDiscovery != null) {
nsdDiscovery.stopDiscovering();
}
}
private void startNsdRegister(String deviceName, int port, String transport, String pinAuth) {
if (nsdDiscovery == null) {
nsdDiscovery = new NsdCastDiscovery(appContext, this::upsertExternal);
}
nsdDiscovery.register(deviceName, port, transport, pinAuth);
}
public void stopNsdRegister() {
if (nsdDiscovery != null) {
nsdDiscovery.unregister();
}
}
private void releaseMulticastLock() {
synchronized (lockGuard) {
if (multicastLock == null || multicastHolds <= 0) {

View File

@@ -0,0 +1,196 @@
package com.foxx.androidcast.discovery;
import android.content.Context;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Build;
import android.util.Log;
import com.foxx.androidcast.CastConfig;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Android NSD (mDNS) discovery/advertise alongside UDP broadcast.
* TXT: port, transport, pin_auth (optional).
*/
public final class NsdCastDiscovery {
private static final String TAG = "NsdCastDiscovery";
public interface Host {
void onNsdDeviceFound(DiscoveryManager.DiscoveredDevice device);
}
private final Context appContext;
private final Host host;
private NsdManager nsdManager;
private NsdManager.DiscoveryListener discoveryListener;
private NsdManager.RegistrationListener registrationListener;
private final AtomicBoolean discovering = new AtomicBoolean(false);
public NsdCastDiscovery(Context context, Host host) {
this.appContext = context.getApplicationContext();
this.host = host;
}
public void startDiscovering() {
if (!discovering.compareAndSet(false, true)) {
return;
}
nsdManager = (NsdManager) appContext.getSystemService(Context.NSD_SERVICE);
if (nsdManager == null) {
discovering.set(false);
return;
}
discoveryListener = new NsdManager.DiscoveryListener() {
@Override
public void onDiscoveryStarted(String serviceType) {
Log.d(TAG, "NSD discovery started");
}
@Override
public void onServiceFound(NsdServiceInfo service) {
if (service == null || service.getServiceType() == null) {
return;
}
if (!service.getServiceType().contains("_androidcast")) {
return;
}
nsdManager.resolveService(service, new NsdManager.ResolveListener() {
@Override
public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
Log.d(TAG, "NSD resolve failed: " + errorCode);
}
@Override
public void onServiceResolved(NsdServiceInfo info) {
publishResolved(info);
}
});
}
@Override
public void onServiceLost(NsdServiceInfo service) {}
@Override
public void onDiscoveryStopped(String serviceType) {
discovering.set(false);
}
@Override
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
Log.w(TAG, "NSD start failed: " + errorCode);
discovering.set(false);
}
@Override
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
discovering.set(false);
}
};
try {
nsdManager.discoverServices(CastConfig.NSD_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD,
discoveryListener);
} catch (Exception e) {
Log.w(TAG, "NSD discover: " + e.getMessage());
discovering.set(false);
}
}
public void stopDiscovering() {
if (!discovering.get() || nsdManager == null || discoveryListener == null) {
discovering.set(false);
return;
}
try {
nsdManager.stopServiceDiscovery(discoveryListener);
} catch (Exception ignored) {
}
discoveryListener = null;
discovering.set(false);
}
public void register(String serviceName, int port, String transport, String pinAuth) {
nsdManager = (NsdManager) appContext.getSystemService(Context.NSD_SERVICE);
if (nsdManager == null) {
return;
}
NsdServiceInfo info = new NsdServiceInfo();
info.setServiceName(sanitizeServiceName(serviceName));
info.setServiceType(CastConfig.NSD_SERVICE_TYPE);
info.setPort(port);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
info.setAttribute("transport", transport != null ? transport : CastConfig.DEFAULT_TRANSPORT);
if (pinAuth != null && !pinAuth.isEmpty()) {
info.setAttribute("pin_auth", pinAuth);
}
}
registrationListener = new NsdManager.RegistrationListener() {
@Override
public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
Log.w(TAG, "NSD register failed: " + errorCode);
}
@Override
public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {}
@Override
public void onServiceRegistered(NsdServiceInfo serviceInfo) {
Log.i(TAG, "NSD registered " + serviceInfo.getServiceName());
}
@Override
public void onServiceUnregistered(NsdServiceInfo serviceInfo) {}
};
try {
nsdManager.registerService(info, NsdManager.PROTOCOL_DNS_SD, registrationListener);
} catch (Exception e) {
Log.w(TAG, "NSD register: " + e.getMessage());
}
}
public void unregister() {
if (nsdManager == null || registrationListener == null) {
return;
}
try {
nsdManager.unregisterService(registrationListener);
} catch (Exception ignored) {
}
registrationListener = null;
}
private void publishResolved(NsdServiceInfo info) {
if (info == null || info.getHost() == null) {
return;
}
String hostAddress = info.getHost().getHostAddress();
int port = info.getPort();
if (port <= 0) {
port = CastConfig.CAST_PORT;
}
String transport = CastConfig.DEFAULT_TRANSPORT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
byte[] t = info.getAttributes().get("transport");
if (t != null) {
transport = new String(t).trim();
}
}
String name = info.getServiceName() != null ? info.getServiceName().replace('_', ' ') : hostAddress;
DiscoveryManager.DiscoveredDevice device = new DiscoveryManager.DiscoveredDevice(
name, hostAddress, port, transport, System.currentTimeMillis());
host.onNsdDeviceFound(device);
}
private static String sanitizeServiceName(String name) {
String s = name != null ? name : "AndroidCast";
s = s.replaceAll("[^a-zA-Z0-9-]", "");
if (s.length() < 1) {
s = "AndroidCast";
}
if (s.length() > 63) {
s = s.substring(0, 63);
}
return s;
}
}

View File

@@ -0,0 +1,90 @@
package com.foxx.androidcast.discovery;
import android.content.Context;
import android.content.SharedPreferences;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
/** Last successful cast targets shown at the top of the sender device list. */
public final class PinnedReceiversStore {
private static final String PREFS = "androidcast_pinned_receivers";
private static final String KEY_ENTRIES = "entries";
private static final int MAX_PINNED = 5;
private PinnedReceiversStore() {}
public static void recordSuccess(Context context, DiscoveryManager.DiscoveredDevice device) {
if (context == null || device == null) {
return;
}
List<DiscoveryManager.DiscoveredDevice> list = load(context);
String key = device.host + ":" + device.port;
list.removeIf(d -> (d.host + ":" + d.port).equals(key));
DiscoveryManager.DiscoveredDevice copy = new DiscoveryManager.DiscoveredDevice(
device.name, device.host, device.port, device.transport, System.currentTimeMillis(),
device.videoCodecs, device.audioCodecs, device.placeholderId, device.protoVersion,
device.pinAuth);
copy.pinnedRecent = true;
list.add(0, copy);
while (list.size() > MAX_PINNED) {
list.remove(list.size() - 1);
}
save(context, list);
}
public static List<DiscoveryManager.DiscoveredDevice> load(Context context) {
List<DiscoveryManager.DiscoveredDevice> out = new ArrayList<>();
SharedPreferences p = context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String raw = p.getString(KEY_ENTRIES, "[]");
try {
JSONArray arr = new JSONArray(raw);
for (int i = 0; i < arr.length(); i++) {
JSONObject o = arr.getJSONObject(i);
DiscoveryManager.DiscoveredDevice d = new DiscoveryManager.DiscoveredDevice(
o.optString("name", "?"),
o.optString("host", ""),
o.optInt("port", 0),
o.optString("transport", "udp"),
o.optLong("last_seen_ms", 0L),
o.optInt("video_codecs", 0),
o.optInt("audio_codecs", 0),
o.optInt("placeholder_id", 0),
o.optLong("proto_version", 0L),
o.optString("pin_auth", ""));
d.pinnedRecent = true;
if (!d.host.isEmpty() && d.port > 0) {
out.add(d);
}
}
} catch (Exception ignored) {
}
return out;
}
private static void save(Context context, List<DiscoveryManager.DiscoveredDevice> list) {
JSONArray arr = new JSONArray();
for (DiscoveryManager.DiscoveredDevice d : list) {
try {
JSONObject o = new JSONObject();
o.put("name", d.name);
o.put("host", d.host);
o.put("port", d.port);
o.put("transport", d.transport);
o.put("last_seen_ms", d.lastSeenMs);
o.put("video_codecs", d.videoCodecs);
o.put("audio_codecs", d.audioCodecs);
o.put("placeholder_id", d.placeholderId);
o.put("proto_version", d.protoVersion);
o.put("pin_auth", d.pinAuth);
arr.put(o);
} catch (Exception ignored) {
}
}
context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_ENTRIES, arr.toString()).apply();
}
}

View File

@@ -247,7 +247,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
@Override
public void onUserLeaveHint() {
super.onUserLeaveHint();
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
tryEnterPictureInPicture();
}
}
@@ -403,7 +404,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
}
videoWidth = width;
videoHeight = height;
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
PictureInPictureHelper.applyParams(this, videoWidth, videoHeight);
}
applyAspectRatio();
@@ -509,7 +511,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
private void onCastSessionStarted() {
castSessionActive = true;
PermissionHelper.remindForReceiverPlayback(this);
if (AppPreferences.isReceiverPipEnabled(this)) {
PermissionHelper.remindForReceiverPlayback(this);
}
updatePipUi();
}
@@ -522,7 +526,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
if (pipButton == null) {
return;
}
boolean show = castSessionActive && PictureInPictureHelper.isSupported()
boolean show = castSessionActive && AppPreferences.isReceiverPipEnabled(this)
&& PictureInPictureHelper.isSupported()
&& PermissionHelper.hasReceiverPlaybackReady(this);
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
if (show) {
@@ -531,7 +536,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
}
private void tryEnterPictureInPicture() {
if (!castSessionActive) {
if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) {
return;
}
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {

View File

@@ -18,6 +18,7 @@ import java.util.Set;
/** Device list with single or multi selection. */
public class DeviceListAdapter extends BaseAdapter {
private final Context context;
private final LayoutInflater inflater;
private final boolean multiSelect;
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
@@ -25,6 +26,7 @@ public class DeviceListAdapter extends BaseAdapter {
private final Set<Integer> selectedPositions = new LinkedHashSet<>();
public DeviceListAdapter(Context context, boolean multiSelect) {
this.context = context;
inflater = LayoutInflater.from(context);
this.multiSelect = multiSelect;
}
@@ -109,7 +111,9 @@ public class DeviceListAdapter extends BaseAdapter {
}
TextView label = row.findViewById(R.id.device_label);
DiscoveryManager.DiscoveredDevice d = devices.get(position);
label.setText(d.name + "" + d.host + " [" + d.transport.toUpperCase() + "]");
String prefix = d.pinnedRecent
? context.getString(R.string.discovery_pinned_label) + " · " : "";
label.setText(prefix + d.name + "" + d.host + " [" + d.transport.toUpperCase() + "]");
boolean selected = multiSelect ? selectedPositions.contains(position) : position == selectedPosition;
row.setActivated(selected);
row.setSelected(selected);

View File

@@ -30,6 +30,7 @@ import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.PlatformCastSupport;
import com.foxx.androidcast.R;
import com.foxx.androidcast.discovery.DiscoveryManager;
import com.foxx.androidcast.discovery.PinnedReceiversStore;
import com.foxx.androidcast.discovery.ReceiverCompatibility;
import com.google.android.material.switchmaterial.SwitchMaterial;
@@ -57,6 +58,7 @@ public class SenderActivity extends DrawerHostActivity {
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
private int discoverySecondsLeft;
private boolean lastScanBurst;
private final Runnable autoScanRunnable = () -> beginScanPass(false);
private String pendingPin;
@@ -148,7 +150,9 @@ public class SenderActivity extends DrawerHostActivity {
@Override
public void onScanStarted() {
discoveryPhase = DiscoveryPhase.SCANNING;
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
long passMs = lastScanBurst
? CastConfig.DISCOVERY_BURST_MS : CastConfig.DISCOVERY_SCAN_PASS_MS;
discoverySecondsLeft = Math.max(1, (int) (passMs / 1000));
discoveryProgress.setVisibility(View.VISIBLE);
}
@@ -209,12 +213,26 @@ public class SenderActivity extends DrawerHostActivity {
if (deviceAdapter == null) {
return;
}
java.util.List<DiscoveryManager.DiscoveredDevice> compatible = new java.util.ArrayList<>();
for (DiscoveryManager.DiscoveredDevice d : lastDiscovered) {
java.util.LinkedHashMap<String, DiscoveryManager.DiscoveredDevice> merged =
new java.util.LinkedHashMap<>();
for (DiscoveryManager.DiscoveredDevice d : PinnedReceiversStore.load(this)) {
if (ReceiverCompatibility.isCompatible(d, castSettings)) {
compatible.add(d);
merged.put(d.host + ":" + d.port, d);
}
}
for (DiscoveryManager.DiscoveredDevice d : lastDiscovered) {
if (!ReceiverCompatibility.isCompatible(d, castSettings)) {
continue;
}
String key = d.host + ":" + d.port;
DiscoveryManager.DiscoveredDevice prev = merged.get(key);
if (prev != null && prev.pinnedRecent) {
d.pinnedRecent = true;
}
merged.put(key, d);
}
java.util.List<DiscoveryManager.DiscoveredDevice> compatible =
new java.util.ArrayList<>(merged.values());
deviceAdapter.setDevices(compatible);
if (statusText == null) {
return;
@@ -297,7 +315,8 @@ public class SenderActivity extends DrawerHostActivity {
discovery.clearDevices();
}
}
discovery.startBrowsing();
lastScanBurst = userInitiated;
discovery.startBrowsing(userInitiated);
}
private void startCastFlow() {
@@ -341,6 +360,7 @@ public class SenderActivity extends DrawerHostActivity {
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
return;
}
PinnedReceiversStore.recordSuccess(this, d);
pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
d.videoCodecs, d.audioCodecs, d.placeholderId));
}

View File

@@ -8,6 +8,7 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.transport.StreamProtectionMode;
import com.foxx.androidcast.network.transport.TransportLossStats;
import org.json.JSONArray;
import org.json.JSONObject;
/**
@@ -36,6 +37,8 @@ public final class SessionStatsRecorder {
private int fpsSamples;
private final TransportLossStats lossTotals = new TransportLossStats();
private final JSONArray samples = new JSONArray();
private long lastSampleMs;
public SessionStatsRecorder(String role, String transport) {
this.role = role;
@@ -82,6 +85,7 @@ public final class SessionStatsRecorder {
if (s == null) {
return;
}
appendTimeSample(s);
if (s.rttMs > 0) {
sumRttMs += s.rttMs;
}
@@ -130,6 +134,39 @@ public final class SessionStatsRecorder {
}
}
private void appendTimeSample(NetworkStatsSnapshot s) {
long now = System.currentTimeMillis();
if (now - lastSampleMs < 2000L) {
return;
}
lastSampleMs = now;
try {
JSONObject pt = new JSONObject();
pt.put("t_ms", now - startedMs);
if (s.rttMs > 0) {
pt.put("rtt_ms", s.rttMs);
}
if (s.lossRatio > 0) {
pt.put("loss_ratio", s.lossRatio);
}
int videoKbps = s.sendBitrateKbps > 0 ? s.sendBitrateKbps : s.recvBitrateKbps;
if (videoKbps > 0) {
pt.put("video_bitrate_kbps", videoKbps);
}
if (s.encodeFps > 0) {
pt.put("encode_fps", s.encodeFps);
}
if (s.targetBitrateKbps > 0) {
pt.put("target_bitrate_kbps", s.targetBitrateKbps);
}
if (s.congestionLevel > 0) {
pt.put("congestion_level", s.congestionLevel);
}
samples.put(pt);
} catch (Exception ignored) {
}
}
public void mergeSettings(CastSettings settings) {
if (settings == null) {
return;
@@ -209,6 +246,9 @@ public final class SessionStatsRecorder {
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
}
root.put("udp", udp);
if (samples.length() > 0) {
root.put("samples", samples);
}
SessionStatsStore.saveSession(context, startedMs, root);
} catch (Exception ignored) {

View File

@@ -218,6 +218,19 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<CheckBox
android:id="@+id/check_receiver_pip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_pip" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/receiver_pip_hint"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -195,10 +195,13 @@
<string name="codec_speex">Speex</string>
<string name="codec_opus">Opus</string>
<string name="codec_audio_auto">AUTO (AAC)</string>
<string name="pip_enter">Картинка в картинке</string>
<string name="pip_unavailable">Режим «картинка в картинке» недоступен на этом устройстве</string>
<string name="pip_disabled_title">Картинка в картинке отключена</string>
<string name="pip_disabled_message">Разрешите картинку в картинке для Android Cast в настройках системы.</string>
<string name="pip_enter">Картинка в картинке (PiP)</string>
<string name="pip_unavailable">Режим «картинка в картинке» (PiP) недоступен на этом устройстве</string>
<string name="pip_disabled_title">PiP отключено</string>
<string name="pip_disabled_message">Разрешите PiP для Android Cast в настройках системы.</string>
<string name="pip_permissions_hint">Уведомления нужны, чтобы приёмник оставался активным при трансляции в фоне.</string>
<string name="permission_notifications_cast">Уведомления (статус трансляции)</string>
<string name="label_receiver_pip">PiP (приёмник)</string>
<string name="receiver_pip_hint">При включении приёмник может свернуться в плавающее окно во время трансляции (нужны уведомления). Выключено, если PiP недоступен.</string>
<string name="discovery_pinned_label">Недавние</string>
</resources>

View File

@@ -201,4 +201,7 @@
<string name="pip_disabled_message">Allow picture-in-picture for Android Cast in system settings, then try again.</string>
<string name="pip_permissions_hint">Notifications keep the receiver visible while casting in the background.</string>
<string name="permission_notifications_cast">Notifications (cast status)</string>
<string name="label_receiver_pip">Enable picture-in-picture (receiver)</string>
<string name="receiver_pip_hint">When on, the receiver can shrink to a floating window during cast (after notifications are allowed). Off by default if PiP is unavailable.</string>
<string name="discovery_pinned_label">Recent</string>
</resources>