1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:58:14 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-15 17:47:09 +02:00
parent 3ddf71064d
commit 65af9331be
16 changed files with 646 additions and 0 deletions

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
private int selectedPosition = -1;
public DeviceListAdapter(Context context) {
inflater = LayoutInflater.from(context);
}
public void setDevices(List<DiscoveryManager.DiscoveredDevice> 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;
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:repeatCount="infinite"
android:repeatMode="reverse"
android:shareInterpolator="false">
<translate
android:duration="500"
android:fromYDelta="0"
android:toYDelta="-14dp" />
<rotate
android:duration="400"
android:fromDegrees="-10"
android:pivotX="50%"
android:pivotY="80%"
android:toDegrees="10" />
</set>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_activated="true" />
<item android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:color="?attr/colorOnSurface" />
</selector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/device_item_selected" android:state_activated="true" />
<item android:drawable="@color/device_item_selected" android:state_selected="true" />
<item android:drawable="@color/device_item_selected" android:state_pressed="true" />
<item android:drawable="@android:color/transparent" />
</selector>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="48"
android:viewportHeight="48">
<!-- Simplified bugdroid (Android green) for awaiting-stream overlay -->
<path
android:fillColor="#3DDC84"
android:pathData="M24,6c-1.1,0 -2,0.9 -2,2v2h-6c-2.2,0 -4,1.8 -4,4v18c0,2.2 1.8,4 4,4h20c2.2,0 4,-1.8 4,-4V14c0,-2.2 -1.8,-4 -4,-4h-6v-2c0,-1.1 -0.9,-2 -2,-2zM14,18h4v4h-4v-4zM30,18h4v4h-4v-4zM12,36l4,-6h16l4,6H12z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M16,18m-2,0a2,2 0,1 1,4 0a2,2 0,1 1,-4 0" />
<path
android:fillColor="#FFFFFF"
android:pathData="M32,18m-2,0a2,2 0,1 1,4 0a2,2 0,1 1,-4 0" />
<path
android:fillColor="#3DDC84"
android:pathData="M22,4h4v4h-4z" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/device_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/device_item_background"
android:padding="14dp"
android:textColor="@color/device_item_text"
android:textSize="16sp" />

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/overlay_awaiting"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#CC000000"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="32dp">
<ImageView
android:id="@+id/image_robot"
android:layout_width="120dp"
android:layout_height="120dp"
android:contentDescription="@string/playback_awaiting_stream"
android:src="@drawable/ic_android_robot" />
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:indeterminate="true" />
<TextView
android:id="@+id/text_awaiting_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:lineSpacingExtra="4dp"
android:maxWidth="320dp"
android:text="@string/playback_awaiting_stream"
android:textColor="#EEFFFFFF"
android:textSize="17sp" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="device_item_selected">#661565C0</color>
</resources>