diff --git a/app/src/main/java/com/foxx/androidcast/media/H264SpsParser.java b/app/src/main/java/com/foxx/androidcast/media/H264SpsParser.java new file mode 100644 index 0000000..4889395 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/H264SpsParser.java @@ -0,0 +1,170 @@ +package com.foxx.androidcast.media; + +import android.util.Log; + +/** Extracts coded picture size from H.264 SPS (csd-0 / Annex-B). */ +public final class H264SpsParser { + private static final String TAG = "H264SpsParser"; + + private H264SpsParser() {} + + public static final class Dimensions { + public final int width; + public final int height; + + public Dimensions(int width, int height) { + this.width = width; + this.height = height; + } + } + + /** Returns null if SPS cannot be parsed. */ + public static Dimensions parseDimensions(byte[] csd0) { + if (csd0 == null || csd0.length < 8) { + return null; + } + byte[] annexB = H264Bitstream.toAnnexB(csd0); + int nalOffset = findNalOffset(annexB, 7); // SPS NAL type + if (nalOffset < 0) { + nalOffset = findNalOffset(annexB, 1); // fallback any SPS-like + } + if (nalOffset < 0) { + return null; + } + try { + BitReader br = new BitReader(annexB, nalOffset + 1); + int profile = br.readBits(8); + br.readBits(8); // constraint + reserved + br.readBits(8); // level + br.readUe(); // seq_parameter_set_id + if (profile == 100 || profile == 110 || profile == 122 || profile == 244 + || profile == 44 || profile == 83 || profile == 86 || profile == 118 + || profile == 128 || profile == 138) { + int chroma = br.readUe(); + if (chroma == 3) { + br.readUe(); // separate_colour_plane_flag offset + } + br.readUe(); // bit_depth_luma_minus8 + br.readUe(); // bit_depth_chroma_minus8 + br.readBits(1); // qpprime_y_zero_transform_bypass + int seqScaling = br.readBits(1); + if (seqScaling == 1) { + int count = (chroma != 3) ? 8 : 12; + int last = 8; + int next = 8; + for (int i = 0; i < count; i++) { + if (br.readBits(1) != 0) { + int delta = br.readSe(); + next = (last + delta + 256) % 256; + } + last = next; + } + } + } + br.readUe(); // log2_max_frame_num_minus4 + int picOrderCnt = br.readUe(); + if (picOrderCnt == 0) { + br.readUe(); + } else if (picOrderCnt == 1) { + br.readBits(1); + br.readSe(); + br.readSe(); + int cycles = br.readUe(); + for (int i = 0; i < cycles; i++) { + br.readSe(); + } + } + br.readUe(); // max_num_ref_frames + br.readBits(1); // gaps_in_frame_num_value_allowed_flag + int picWidthMbs = br.readUe() + 1; + int picHeightMbs = br.readUe() + 1; + int frameMbsOnly = br.readBits(1); + if (frameMbsOnly == 0) { + br.readBits(1); // mb_adaptive_frame_field_flag + } + br.readBits(1); // direct_8x8_inference_flag + int frameCrop = br.readBits(1); + int cropLeft = 0; + int cropRight = 0; + int cropTop = 0; + int cropBottom = 0; + if (frameCrop == 1) { + cropLeft = br.readUe(); + cropRight = br.readUe(); + cropTop = br.readUe(); + cropBottom = br.readUe(); + } + int width = picWidthMbs * 16 - (cropLeft + cropRight) * 2; + int height = (2 - frameMbsOnly) * picHeightMbs * 16 - (cropTop + cropBottom) * 2; + if (width > 0 && height > 0) { + return new Dimensions(width, height); + } + } catch (Exception e) { + Log.w(TAG, "SPS parse failed", e); + } + return null; + } + + private static int findNalOffset(byte[] data, int nalType) { + for (int i = 0; i + 4 < data.length; i++) { + if (data[i] == 0 && data[i + 1] == 0) { + int start = -1; + if (data[i + 2] == 1) { + start = i + 3; + } else if (data[i + 2] == 0 && data[i + 3] == 1) { + start = i + 4; + } + if (start > 0 && start < data.length && (data[start] & 0x1F) == nalType) { + return start; + } + } + } + return -1; + } + + private static final class BitReader { + private final byte[] data; + private int bitPos; + + BitReader(byte[] data, int byteOffset) { + this.data = data; + this.bitPos = byteOffset * 8; + } + + int readBits(int n) { + int val = 0; + for (int i = 0; i < n; i++) { + int byteIndex = bitPos / 8; + if (byteIndex >= data.length) { + throw new IndexOutOfBoundsException(); + } + int bitIndex = 7 - (bitPos % 8); + int bit = (data[byteIndex] >> bitIndex) & 1; + val = (val << 1) | bit; + bitPos++; + } + return val; + } + + int readUe() { + int zeros = 0; + while (readBits(1) == 0) { + zeros++; + if (zeros > 31) { + throw new IndexOutOfBoundsException(); + } + } + int val = 1; + for (int i = 0; i < zeros; i++) { + val = (val << 1) | readBits(1); + } + return val - 1; + } + + int readSe() { + int code = readUe(); + int sign = ((code & 1) << 1) - 1; + return ((code + 1) >> 1) * sign; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/StreamDimensionResolver.java b/app/src/main/java/com/foxx/androidcast/media/StreamDimensionResolver.java new file mode 100644 index 0000000..2776178 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/StreamDimensionResolver.java @@ -0,0 +1,42 @@ +package com.foxx.androidcast.media; + +/** + * Resolves display dimensions using STREAM_CONFIG and SPS (csd-0). + * SPS confirms coded size; config reflects sender VirtualDisplay (preferred for rotation). + */ +public final class StreamDimensionResolver { + private StreamDimensionResolver() {} + + public static final class Size { + public final int width; + public final int height; + public final boolean fromSps; + + public Size(int width, int height, boolean fromSps) { + this.width = width; + this.height = height; + this.fromSps = fromSps; + } + } + + public static Size resolve(int configWidth, int configHeight, byte[] csd0) { + H264SpsParser.Dimensions sps = H264SpsParser.parseDimensions(csd0); + if (configWidth > 0 && configHeight > 0) { + if (sps != null && dimensionsSwapped(configWidth, configHeight, sps.width, sps.height)) { + // Encoder SPS matches rotated buffer — use SPS for display aspect + return new Size(sps.width, sps.height, true); + } + return new Size(configWidth, configHeight, false); + } + if (sps != null) { + return new Size(sps.width, sps.height, true); + } + return new Size(Math.max(configWidth, 2), Math.max(configHeight, 2), false); + } + + private static boolean dimensionsSwapped(int cw, int ch, int sw, int sh) { + boolean configLandscape = cw >= ch; + boolean spsLandscape = sw >= sh; + return configLandscape != spsLandscape && cw != sw && ch != sh; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java new file mode 100644 index 0000000..3a6b9ee --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java @@ -0,0 +1,17 @@ +package com.foxx.androidcast.network.control; + +import com.foxx.androidcast.CastConfig; + +/** Actionable guidance produced by the network control plane (may be no-op). */ +public class NetworkControlAdvice { + public boolean sendStats; + public boolean sendControl; + public int suggestedBitrateKbps; + public String suggestedTransport = CastConfig.TRANSPORT_TCP; + public boolean switchTransport; + public int congestionLevel; + + public static NetworkControlAdvice none() { + return new NetworkControlAdvice(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlPlane.java new file mode 100644 index 0000000..5066901 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlPlane.java @@ -0,0 +1,22 @@ +package com.foxx.androidcast.network.control; + +/** + * Pluggable network intelligence: bandwidth estimation, congestion, transport switching. + * Replace {@link PassThroughNetworkControlPlane} with a real implementation when ready. + */ +public interface NetworkControlPlane { + /** Called periodically from the cast loop (sender or receiver). */ + NetworkControlAdvice tick(long nowMs); + + /** Peer stats received over the wire. */ + void onPeerStats(NetworkStatsSnapshot stats); + + /** Local stats to publish to the peer. */ + NetworkStatsSnapshot buildLocalStats(long nowMs); + + /** Apply control message received from peer (bitrate/transport hints). */ + void onPeerControl(int suggestedBitrateKbps, String suggestedTransport, boolean switchTransport, int congestionLevel); + + /** Local advice to send after {@link #tick(long)}. */ + NetworkControlAdvice getOutboundAdvice(); +} diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java new file mode 100644 index 0000000..e00ee02 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkFeedbackManager.java @@ -0,0 +1,91 @@ +package com.foxx.androidcast.network.control; + +import android.util.Log; + +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.CastSession; + +import java.io.IOException; + +/** Bridges {@link NetworkControlPlane} to {@link CastSession} send/receive. */ +public class NetworkFeedbackManager { + private static final String TAG = "NetworkFeedback"; + + private final NetworkControlPlane plane; + private final CastSession session; + private final boolean isSender; + + public NetworkFeedbackManager(CastSession session, boolean isSender, NetworkControlPlane plane) { + this.session = session; + this.isSender = isSender; + this.plane = plane != null ? plane : new PassThroughNetworkControlPlane(); + } + + public NetworkFeedbackManager(CastSession session, boolean isSender) { + this(session, isSender, new PassThroughNetworkControlPlane()); + } + + public NetworkControlPlane getPlane() { + return plane; + } + + /** Call on each stream loop iteration (~500ms). */ + public void tick() { + long now = System.currentTimeMillis(); + NetworkControlAdvice advice = plane.tick(now); + if (advice.sendStats) { + sendStats(plane.buildLocalStats(now)); + } + if (advice.sendControl) { + sendControl(advice); + } + } + + public boolean handleMessage(CastProtocol.Message msg) { + try { + switch (msg.type) { + case CastProtocol.MSG_NETWORK_STATS: + plane.onPeerStats(CastProtocol.parseNetworkStats(msg.payload)); + return true; + case CastProtocol.MSG_NETWORK_CONTROL: + CastProtocol.NetworkControl ctrl = CastProtocol.parseNetworkControl(msg.payload); + plane.onPeerControl(ctrl.suggestedBitrateKbps, ctrl.suggestedTransport, + ctrl.switchTransport, ctrl.congestionLevel); + applyAdvice(plane.getOutboundAdvice()); + return true; + default: + return false; + } + } catch (IOException e) { + Log.w(TAG, "Network message parse failed", e); + return false; + } + } + + private void sendStats(NetworkStatsSnapshot stats) { + try { + session.send(CastProtocol.MSG_NETWORK_STATS, CastProtocol.networkStatsPayload(stats)); + } catch (IOException e) { + Log.w(TAG, "Send stats failed", e); + } + } + + private void sendControl(NetworkControlAdvice advice) { + try { + session.send(CastProtocol.MSG_NETWORK_CONTROL, + CastProtocol.networkControlPayload(advice)); + } catch (IOException e) { + Log.w(TAG, "Send control failed", e); + } + } + + /** Hook for sender to apply bitrate/transport hints (stub-friendly). */ + public void applyAdvice(NetworkControlAdvice advice) { + if (advice.suggestedBitrateKbps > 0 && isSender) { + Log.d(TAG, "Bitrate hint (not applied yet): " + advice.suggestedBitrateKbps); + } + if (advice.switchTransport) { + Log.d(TAG, "Transport switch hint (not applied yet): " + advice.suggestedTransport); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java new file mode 100644 index 0000000..91052d7 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java @@ -0,0 +1,26 @@ +package com.foxx.androidcast.network.control; + +/** Point-in-time network metrics exchanged between sender and receiver. */ +public class NetworkStatsSnapshot { + public long sequence; + public long timestampMs; + public int rttMs; + public float lossRatio; + public int sendBitrateKbps; + public int recvBitrateKbps; + public int congestionLevel; + public int jitterMs; + + public NetworkStatsSnapshot copy() { + NetworkStatsSnapshot c = new NetworkStatsSnapshot(); + c.sequence = sequence; + c.timestampMs = timestampMs; + c.rttMs = rttMs; + c.lossRatio = lossRatio; + c.sendBitrateKbps = sendBitrateKbps; + c.recvBitrateKbps = recvBitrateKbps; + c.congestionLevel = congestionLevel; + c.jitterMs = jitterMs; + return c; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java new file mode 100644 index 0000000..5d16b99 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java @@ -0,0 +1,60 @@ +package com.foxx.androidcast.network.control; + +import com.foxx.androidcast.CastConfig; + +/** + * Default no-op controller: emits heartbeat stats, ignores congestion, never switches transport. + * Swap this class for smart logic without changing protocol or session code. + */ +public class PassThroughNetworkControlPlane implements NetworkControlPlane { + private static final long STATS_INTERVAL_MS = 2_000; + + private long sequence; + private long lastStatsMs; + private NetworkStatsSnapshot lastPeer; + private final NetworkControlAdvice outbound = NetworkControlAdvice.none(); + + @Override + public NetworkControlAdvice tick(long nowMs) { + outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS; + outbound.sendControl = false; + outbound.suggestedBitrateKbps = 0; + outbound.suggestedTransport = CastConfig.TRANSPORT_TCP; + outbound.switchTransport = false; + outbound.congestionLevel = 0; + if (outbound.sendStats) { + lastStatsMs = nowMs; + } + return outbound; + } + + @Override + public void onPeerStats(NetworkStatsSnapshot stats) { + lastPeer = stats; + } + + @Override + public NetworkStatsSnapshot buildLocalStats(long nowMs) { + NetworkStatsSnapshot s = new NetworkStatsSnapshot(); + s.sequence = ++sequence; + s.timestampMs = nowMs; + s.rttMs = lastPeer != null ? lastPeer.rttMs : 0; + s.lossRatio = 0f; + s.sendBitrateKbps = 0; + s.recvBitrateKbps = 0; + s.congestionLevel = 0; + s.jitterMs = 0; + return s; + } + + @Override + public void onPeerControl(int suggestedBitrateKbps, String suggestedTransport, + boolean switchTransport, int congestionLevel) { + // Pass-through: no local adjustments yet. + } + + @Override + public NetworkControlAdvice getOutboundAdvice() { + return outbound; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPrefs.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPrefs.java new file mode 100644 index 0000000..26039af --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPrefs.java @@ -0,0 +1,24 @@ +package com.foxx.androidcast.receiver; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Receiver UI preferences (PIN is entered each session; audio choice is remembered). */ +public final class ReceiverPrefs { + private static final String PREFS = "receiver_prefs"; + private static final String KEY_ALLOW_AUDIO = "allow_audio"; + + private ReceiverPrefs() {} + + public static boolean isAllowAudio(Context context) { + return prefs(context).getBoolean(KEY_ALLOW_AUDIO, true); + } + + public static void setAllowAudio(Context context, boolean allow) { + prefs(context).edit().putBoolean(KEY_ALLOW_AUDIO, allow).apply(); + } + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java new file mode 100644 index 0000000..3261bd0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java @@ -0,0 +1,84 @@ +package com.foxx.androidcast.sender; + +import android.content.Context; +import android.graphics.Typeface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import com.foxx.androidcast.R; +import com.foxx.androidcast.discovery.DiscoveryManager; + +import java.util.ArrayList; +import java.util.List; + +/** Device list with clear selection highlight. */ +public class DeviceListAdapter extends BaseAdapter { + private final LayoutInflater inflater; + private final List devices = new ArrayList<>(); + private int selectedPosition = -1; + + public DeviceListAdapter(Context context) { + inflater = LayoutInflater.from(context); + } + + public void setDevices(List list) { + devices.clear(); + if (list != null) { + devices.addAll(list); + } + if (selectedPosition >= devices.size()) { + selectedPosition = -1; + } + notifyDataSetChanged(); + } + + public void setSelectedPosition(int position) { + selectedPosition = position; + notifyDataSetChanged(); + } + + public int getSelectedPosition() { + return selectedPosition; + } + + public DiscoveryManager.DiscoveredDevice getSelectedDevice() { + if (selectedPosition < 0 || selectedPosition >= devices.size()) { + return null; + } + return devices.get(selectedPosition); + } + + @Override + public int getCount() { + return devices.size(); + } + + @Override + public DiscoveryManager.DiscoveredDevice getItem(int position) { + return devices.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View row = convertView; + if (row == null) { + row = inflater.inflate(R.layout.item_device, parent, false); + } + TextView label = row.findViewById(R.id.device_label); + DiscoveryManager.DiscoveredDevice d = devices.get(position); + label.setText(d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]"); + boolean selected = position == selectedPosition; + row.setActivated(selected); + row.setSelected(selected); + label.setTypeface(null, selected ? Typeface.BOLD : Typeface.NORMAL); + return row; + } +} diff --git a/app/src/main/res/anim/robot_dance.xml b/app/src/main/res/anim/robot_dance.xml new file mode 100644 index 0000000..961182e --- /dev/null +++ b/app/src/main/res/anim/robot_dance.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/color/device_item_text.xml b/app/src/main/res/color/device_item_text.xml new file mode 100644 index 0000000..5baa79c --- /dev/null +++ b/app/src/main/res/color/device_item_text.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/device_item_background.xml b/app/src/main/res/drawable/device_item_background.xml new file mode 100644 index 0000000..ec3de96 --- /dev/null +++ b/app/src/main/res/drawable/device_item_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_android_robot.xml b/app/src/main/res/drawable/ic_android_robot.xml new file mode 100644 index 0000000..fea980d --- /dev/null +++ b/app/src/main/res/drawable/ic_android_robot.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/item_device.xml b/app/src/main/res/layout/item_device.xml new file mode 100644 index 0000000..e584108 --- /dev/null +++ b/app/src/main/res/layout/item_device.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/overlay_stream_awaiting.xml b/app/src/main/res/layout/overlay_stream_awaiting.xml new file mode 100644 index 0000000..a441e81 --- /dev/null +++ b/app/src/main/res/layout/overlay_stream_awaiting.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..fef2422 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #661565C0 +