mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
snap
This commit is contained in:
170
app/src/main/java/com/foxx/androidcast/media/H264SpsParser.java
Normal file
170
app/src/main/java/com/foxx/androidcast/media/H264SpsParser.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
19
app/src/main/res/anim/robot_dance.xml
Normal file
19
app/src/main/res/anim/robot_dance.xml
Normal 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>
|
||||
6
app/src/main/res/color/device_item_text.xml
Normal file
6
app/src/main/res/color/device_item_text.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/device_item_background.xml
Normal file
7
app/src/main/res/drawable/device_item_background.xml
Normal 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>
|
||||
21
app/src/main/res/drawable/ic_android_robot.xml
Normal file
21
app/src/main/res/drawable/ic_android_robot.xml
Normal 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>
|
||||
9
app/src/main/res/layout/item_device.xml
Normal file
9
app/src/main/res/layout/item_device.xml
Normal 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" />
|
||||
44
app/src/main/res/layout/overlay_stream_awaiting.xml
Normal file
44
app/src/main/res/layout/overlay_stream_awaiting.xml
Normal 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>
|
||||
4
app/src/main/res/values-night/colors.xml
Normal file
4
app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="device_item_selected">#661565C0</color>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user