diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index d97a17f..62fe7c9 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -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); diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java index 333b6eb..27f3d03 100644 --- a/app/src/main/java/com/foxx/androidcast/CastConfig.java +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -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"; diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java index a5cff87..f36283c 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java @@ -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(); diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java index 6eab506..bb22036 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java @@ -119,6 +119,12 @@ public class CastSettingsFragment extends Fragment { }); } + @Override + public void onResume() { + super.onResume(); + CastSettingsBinder.refreshReceiverPipFromPermissions((AppCompatActivity) requireActivity()); + } + @Override public void onPause() { saveAll(); diff --git a/app/src/main/java/com/foxx/androidcast/PermissionHelper.java b/app/src/main/java/com/foxx/androidcast/PermissionHelper.java index a35d3e7..924030d 100644 --- a/app/src/main/java/com/foxx/androidcast/PermissionHelper.java +++ b/app/src/main/java/com/foxx/androidcast/PermissionHelper.java @@ -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 missingForReceiverPlayback(Activity activity) { + private static List missingForReceiverPlayback(android.content.Context context) { List 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; diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index 5372757..9afd98f 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -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) { diff --git a/app/src/main/java/com/foxx/androidcast/discovery/NsdCastDiscovery.java b/app/src/main/java/com/foxx/androidcast/discovery/NsdCastDiscovery.java new file mode 100644 index 0000000..43af29a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/discovery/NsdCastDiscovery.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/discovery/PinnedReceiversStore.java b/app/src/main/java/com/foxx/androidcast/discovery/PinnedReceiversStore.java new file mode 100644 index 0000000..ba4dcbb --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/discovery/PinnedReceiversStore.java @@ -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 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 load(Context context) { + List 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 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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index 039f4a9..20882e5 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -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)) { diff --git a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java index 21f894d..3a97816 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java +++ b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java @@ -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 devices = new ArrayList<>(); @@ -25,6 +26,7 @@ public class DeviceListAdapter extends BaseAdapter { private final Set 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); diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java index db1bbc8..29dbb3c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -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 compatible = new java.util.ArrayList<>(); - for (DiscoveryManager.DiscoveredDevice d : lastDiscovered) { + java.util.LinkedHashMap 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 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)); } diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java index 64f31bc..c3e317f 100644 --- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java @@ -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) { diff --git a/app/src/main/res/layout/fragment_cast_settings.xml b/app/src/main/res/layout/fragment_cast_settings.xml index 9a12071..62aedd3 100644 --- a/app/src/main/res/layout/fragment_cast_settings.xml +++ b/app/src/main/res/layout/fragment_cast_settings.xml @@ -218,6 +218,19 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> + + + + Speex Opus AUTO (AAC) - Картинка в картинке - Режим «картинка в картинке» недоступен на этом устройстве - Картинка в картинке отключена - Разрешите картинку в картинке для Android Cast в настройках системы. + Картинка в картинке (PiP) + Режим «картинка в картинке» (PiP) недоступен на этом устройстве + PiP отключено + Разрешите PiP для Android Cast в настройках системы. Уведомления нужны, чтобы приёмник оставался активным при трансляции в фоне. Уведомления (статус трансляции) + PiP (приёмник) + При включении приёмник может свернуться в плавающее окно во время трансляции (нужны уведомления). Выключено, если PiP недоступен. + Недавние diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4ab4aec..316a6ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,4 +201,7 @@ Allow picture-in-picture for Android Cast in system settings, then try again. Notifications keep the receiver visible while casting in the background. Notifications (cast status) + Enable picture-in-picture (receiver) + When on, the receiver can shrink to a floating window during cast (after notifications are allowed). Off by default if PiP is unavailable. + Recent diff --git a/settings.gradle b/settings.gradle index 84d5c92..1c5ad14 100644 --- a/settings.gradle +++ b/settings.gradle @@ -18,3 +18,5 @@ dependencyResolutionManagement { rootProject.name = "AndroidCast" include ':app' +include ':session-studio' +project(':session-studio').projectDir = file('desktop/session-studio')