1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:18:42 +03:00

snap good

This commit is contained in:
Anton Afanasyeu
2026-05-15 18:37:53 +02:00
parent 17fddbb905
commit ff96afc12b
15 changed files with 361 additions and 61 deletions

View File

@@ -10,7 +10,8 @@ public final class CastConfig {
public static final String TRANSPORT_TCP = "tcp";
public static final String TRANSPORT_UDP = "udp";
public static final int VIDEO_I_FRAME_INTERVAL = 2;
/** Seconds between IDR frames (1 = faster recovery, slightly more bandwidth). */
public static final int VIDEO_I_FRAME_INTERVAL = 1;
public static final int AUDIO_SAMPLE_RATE = 44100;
public static final int AUDIO_CHANNEL_COUNT = 2;

View File

@@ -7,9 +7,9 @@ public class CastSettings implements Serializable {
public static final String EXTRA = "cast_settings";
public enum Quality {
LOW(800_000, 20),
MEDIUM(2_000_000, 24),
HIGH(4_000_000, 30);
LOW(1_500_000, 24),
MEDIUM(5_000_000, 30),
HIGH(10_000_000, 30);
public final int videoBitrate;
public final int videoFps;
@@ -38,7 +38,7 @@ public class CastSettings implements Serializable {
private String transport = CastConfig.TRANSPORT_TCP;
private Quality quality = Quality.MEDIUM;
private Resolution resolution = Resolution.FULL;
private Resolution resolution = Resolution.HD_720P;
private boolean audioEnabled;
public CastSettings() {

View File

@@ -44,6 +44,7 @@ public final class SettingsUi {
};
quality.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, qualities));
quality.setSelection(1);
resolution.setSelection(CastSettings.Resolution.HD_720P.ordinal());
quality.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {

View File

@@ -87,10 +87,14 @@ public class DiscoveryManager {
}
public void startAnnouncing(String deviceName, int port, String transport) {
startAnnouncing(deviceName, port, transport, true);
}
public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
if (!running.compareAndSet(false, true)) {
return;
}
executor.execute(() -> announceLoop(deviceName, port, transport));
executor.execute(() -> announceLoop(deviceName, port, transport, listening));
}
private void browseLoop() {
@@ -115,7 +119,7 @@ public class DiscoveryManager {
}
}
private void announceLoop(String deviceName, int port, String transport) {
private void announceLoop(String deviceName, int port, String transport, boolean listening) {
acquireMulticastLock();
try (DatagramSocket sock = new DatagramSocket()) {
socket = sock;
@@ -126,6 +130,7 @@ public class DiscoveryManager {
json.put("name", deviceName);
json.put("port", port);
json.put("transport", transport);
json.put("listening", listening);
byte[] data = json.toString().getBytes(StandardCharsets.UTF_8);
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(data, data.length, broadcast, CastConfig.DISCOVERY_PORT);
@@ -151,6 +156,9 @@ public class DiscoveryManager {
String name = json.optString("name", "Unknown");
int port = json.optInt("port", CastConfig.CAST_PORT);
String transport = json.optString("transport", CastConfig.TRANSPORT_TCP);
if (!json.optBoolean("listening", true)) {
return;
}
String host = packet.getAddress().getHostAddress();
String key = host + ":" + port;
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, System.currentTimeMillis());

View File

@@ -30,6 +30,14 @@ public class CastSession {
transport.listen(port);
}
public void prepareListen(int port) throws IOException {
transport.prepareListen(port);
}
public void acceptClient() throws IOException {
transport.acceptClient();
}
public void connect(String host, int port) throws IOException {
transport.connect(host, port);
}
@@ -91,6 +99,10 @@ public class CastSession {
return transport.receive(timeoutMs);
}
public void releaseConnection() {
transport.releaseConnection();
}
public void close() {
transport.close();
}

View File

@@ -8,6 +8,14 @@ public interface CastTransport extends Closeable {
/** Server: bind and wait for first peer datagram / connection. */
void listen(int port) throws IOException;
/** Server: bind listen socket only (port open for inbound TCP). */
default void prepareListen(int port) throws IOException {
listen(port);
}
/** Server: block until a client connects (TCP) or no-op (UDP). */
default void acceptClient() throws IOException {}
/** Client: connect to host:port. */
void connect(String host, int port) throws IOException;
@@ -18,6 +26,11 @@ public interface CastTransport extends Closeable {
String getModeLabel();
/** Server: drop the active client socket but keep the listen socket bound. */
default void releaseConnection() {
close();
}
@Override
void close();
}

View File

@@ -25,18 +25,40 @@ public class TcpCastTransport implements CastTransport {
private DataInputStream in;
private DataOutputStream out;
/** True once {@link #listen} has bound the server socket (port is accepting connections). */
public boolean isListening() {
return serverSocket != null && !serverSocket.isClosed();
}
@Override
public void prepareListen(int port) throws IOException {
ensureServerSocket(port);
}
@Override
public void acceptClient() throws IOException {
acceptClientConnection();
}
@Override
public void listen(int port) throws IOException {
prepareListen(port);
acceptClient();
}
private void ensureServerSocket(int port) throws IOException {
if (serverSocket != null && !serverSocket.isClosed()) {
return;
}
serverSocket = new ServerSocket();
serverSocket.setReuseAddress(true);
serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
}
private void acceptClientConnection() throws IOException {
releaseConnection();
socket = serverSocket.accept();
socket.setTcpNoDelay(true);
try {
serverSocket.close();
} catch (IOException ignored) {
}
serverSocket = null;
openStreams();
}
@@ -116,29 +138,38 @@ public class TcpCastTransport implements CastTransport {
}
@Override
public void close() {
public void releaseConnection() {
synchronized (sendLock) {
synchronized (receiveLock) {
closeSockets();
closeClientOnly();
}
}
}
private void closeSockets() {
@Override
public void close() {
synchronized (sendLock) {
synchronized (receiveLock) {
closeClientOnly();
if (serverSocket != null) {
try {
serverSocket.close();
} catch (Exception ignored) {
}
serverSocket = null;
}
}
}
}
private void closeClientOnly() {
try {
if (socket != null) {
socket.close();
}
} catch (Exception ignored) {
}
try {
if (serverSocket != null && !serverSocket.isClosed()) {
serverSocket.close();
}
} catch (Exception ignored) {
}
socket = null;
serverSocket = null;
bufferedIn = null;
in = null;
out = null;

View File

@@ -24,12 +24,19 @@ public class UdpCastTransport implements CastTransport {
private final Map<Integer, Reassembly> pending = new ConcurrentHashMap<>();
@Override
public void listen(int port) throws IOException {
public void prepareListen(int port) throws IOException {
if (socket != null && !socket.isClosed()) {
return;
}
socket = new DatagramSocket(null);
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(port));
socket.setSoTimeout(500);
// peer set on first received packet
}
@Override
public void listen(int port) throws IOException {
prepareListen(port);
}
@Override
@@ -160,6 +167,11 @@ public class UdpCastTransport implements CastTransport {
return "UDP";
}
@Override
public void releaseConnection() {
peer = null;
}
@Override
public void close() {
if (socket != null) {

View File

@@ -177,10 +177,12 @@ public class ReceiverCastService extends Service {
videoDecoder = new VideoDecoder();
audioDecoder = new AudioDecoder();
discovery = new DiscoveryManager(this);
discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT, settings.getTransport());
session = new ReceiverSession(pin, settings, new ReceiverSession.Listener() {
@Override
public void onListening() {
mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
}
@Override
public void onStatus(String s) {
updateStatus(s);
@@ -447,6 +449,16 @@ public class ReceiverCastService extends Service {
playbackLaunched = false;
}
private void startDiscoveryAnnouncing() {
if (discovery != null) {
return;
}
discovery = new DiscoveryManager(this);
discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT,
settings.getTransport(), true);
Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT);
}
private void stopSessionOnly() {
if (discovery != null) {
discovery.stop();

View File

@@ -6,6 +6,7 @@ import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
@@ -35,6 +36,9 @@ public class ReceiverSession {
void onDisconnected();
/** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
void onListening();
void onNetworkAdviceApplied();
void onPeerNetworkStats(NetworkStatsSnapshot stats);
@@ -47,6 +51,7 @@ public class ReceiverSession {
private final AtomicBoolean running = new AtomicBoolean(false);
private CastSession session;
private CastTransport listenTransport;
private NetworkFeedbackManager networkFeedback;
public ReceiverSession(String pin, CastSettings localSettings, Listener listener) {
@@ -68,6 +73,10 @@ public class ReceiverSession {
session.close();
session = null;
}
if (listenTransport != null) {
listenTransport.close();
listenTransport = null;
}
}
public void sendAudioConsent(boolean accept) {
@@ -84,11 +93,18 @@ public class ReceiverSession {
private void runServer() {
while (running.get()) {
try {
session = new CastSession(CastTransportFactory.create(localSettings));
networkFeedback = new NetworkFeedbackManager(session, false);
session.listen(CastConfig.CAST_PORT);
postStatus("Listening (" + session.getTransport().getModeLabel() + ") port "
if (listenTransport == null) {
listenTransport = CastTransportFactory.create(localSettings);
session = new CastSession(listenTransport);
session.prepareListen(CastConfig.CAST_PORT);
listener.onListening();
postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
+ CastConfig.CAST_PORT);
} else if (session == null) {
session = new CastSession(listenTransport);
}
networkFeedback = new NetworkFeedbackManager(session, false);
session.acceptClient();
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
listener.onAuthenticated(hs.senderName, hs.settings);
postStatus("Streaming from " + hs.senderName);
@@ -115,8 +131,7 @@ public class ReceiverSession {
}
} finally {
if (session != null) {
session.close();
session = null;
session.releaseConnection();
}
networkFeedback = null;
listener.onDisconnected();

View File

@@ -12,7 +12,9 @@ public final class StreamMetrics {
private long audioBytes;
private long renderedFrames;
private long windowVideoBytes;
private long windowVideoPackets;
private long windowStartMs;
private float lastSecondInFps;
private long lastVideoPacketMs;
private long lastRenderedFrameMs;
private long firstVideoPacketMs;
@@ -29,7 +31,9 @@ public final class StreamMetrics {
audioBytes = 0;
renderedFrames = 0;
windowVideoBytes = 0;
windowVideoPackets = 0;
windowStartMs = 0;
lastSecondInFps = 0f;
lastVideoPacketMs = 0;
lastRenderedFrameMs = 0;
firstVideoPacketMs = 0;
@@ -50,6 +54,14 @@ public final class StreamMetrics {
if (windowStartMs == 0) {
windowStartMs = now;
}
long windowElapsed = now - windowStartMs;
if (windowElapsed >= 1000) {
lastSecondInFps = windowVideoPackets;
windowVideoPackets = 0;
windowVideoBytes = 0;
windowStartMs = now;
}
windowVideoPackets++;
windowVideoBytes += bytes;
if (firstVideoPacketMs == 0) {
firstVideoPacketMs = now;
@@ -83,7 +95,7 @@ public final class StreamMetrics {
public synchronized String format(String transport, int streamW, int streamH, int renderW, int renderH,
boolean idle) {
long now = System.currentTimeMillis();
float inFps = computeFps(videoPackets, firstVideoPacketMs, now);
float inFps = lastSecondInFps > 0 ? lastSecondInFps : computeFps(videoPackets, firstVideoPacketMs, now);
float renderFps = computeFps(renderedFrames, firstVideoPacketMs, lastRenderedFrameMs);
int localKbps = computeLocalKbps(now);
long idleMs = lastVideoPacketMs > 0 ? now - lastVideoPacketMs : -1;

View File

@@ -0,0 +1,127 @@
package com.foxx.androidcast.sender;
import android.util.Log;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import java.io.IOException;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Background TCP/UDP send queue so encoding is not blocked by network I/O. */
public final class CastSendPump {
private static final String TAG = "CastSendPump";
private static final int MAX_QUEUE = 48;
private static final int MAX_P_FRAMES_BACKLOG = 36;
private final LinkedBlockingQueue<Outgoing> queue = new LinkedBlockingQueue<>(MAX_QUEUE);
private final AtomicBoolean running = new AtomicBoolean(false);
private Thread thread;
private CastSession session;
private Runnable onSendFailed;
public void start(CastSession session, Runnable onSendFailed) {
this.session = session;
this.onSendFailed = onSendFailed;
if (!running.compareAndSet(false, true)) {
return;
}
thread = new Thread(this::run, "CastSendPump");
thread.start();
}
public void stop() {
running.set(false);
if (thread != null) {
thread.interrupt();
try {
thread.join(1500);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
thread = null;
}
queue.clear();
session = null;
}
public void enqueueVideo(long ptsUs, boolean keyFrame, byte[] annexBReadyPayload) {
if (!running.get()) {
return;
}
Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload);
if (keyFrame) {
queue.offer(item);
return;
}
if (queue.size() >= MAX_P_FRAMES_BACKLOG) {
return;
}
if (!queue.offer(item)) {
queue.poll();
queue.offer(item);
}
}
public void enqueue(byte type, byte[] payload) {
if (!running.get()) {
return;
}
Outgoing item = Outgoing.raw(type, payload);
while (running.get() && !queue.offer(item)) {
queue.poll();
}
}
private void run() {
while (running.get()) {
try {
Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS);
if (item == null || session == null) {
continue;
}
session.send(item.type, item.buildPayload());
} catch (InterruptedException e) {
if (!running.get()) {
break;
}
} catch (IOException e) {
Log.w(TAG, "Send failed: " + e.getMessage());
if (onSendFailed != null) {
onSendFailed.run();
}
}
}
}
private static final class Outgoing {
final byte type;
final byte[] payload;
final long ptsUs;
final boolean keyFrame;
private Outgoing(byte type, byte[] payload, long ptsUs, boolean keyFrame) {
this.type = type;
this.payload = payload;
this.ptsUs = ptsUs;
this.keyFrame = keyFrame;
}
static Outgoing raw(byte type, byte[] payload) {
return new Outgoing(type, payload, 0, false);
}
static Outgoing video(long ptsUs, boolean keyFrame, byte[] payload) {
return new Outgoing(CastProtocol.MSG_VIDEO_FRAME, payload, ptsUs, keyFrame);
}
byte[] buildPayload() throws IOException {
if (type != CastProtocol.MSG_VIDEO_FRAME) {
return payload;
}
return CastProtocol.videoFramePayload(ptsUs, keyFrame, payload);
}
}
}

View File

@@ -63,6 +63,7 @@ public class ScreenCastService extends Service {
private AudioEncoder audioEncoder;
private AudioCapture audioCapture;
private CastSession session;
private CastSendPump sendPump;
private NetworkFeedbackManager networkFeedback;
private PowerManager.WakeLock wakeLock;
private Thread ioThread;
@@ -158,6 +159,8 @@ public class ScreenCastService extends Service {
connectWithRetry(host, port, settings);
updateStatus(getString(R.string.authenticating));
session.clientHandshake(deviceName != null ? deviceName : "Sender", pin, settings);
sendPump = new CastSendPump();
sendPump.start(session, () -> stopping.set(true));
networkFeedback = new NetworkFeedbackManager(session, true);
casting.set(true);
activeSettings = settings;
@@ -175,7 +178,6 @@ public class ScreenCastService extends Service {
}
}
}
Thread.sleep(400);
} catch (java.net.SocketException e) {
if (!stopping.get()) {
Log.i(TAG, "Connection lost: " + e.getMessage());
@@ -227,7 +229,11 @@ public class ScreenCastService extends Service {
}
}
}
throw new IOException("Could not connect to " + host + ":" + port, last);
String detail = last != null && last.getMessage() != null ? last.getMessage() : "";
if (detail.contains("ECONNREFUSED")) {
throw new IOException(getString(R.string.error_receiver_refused, host, port), last);
}
throw new IOException(getString(R.string.error_connect_failed, host, port), last);
}
private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException {
@@ -237,8 +243,12 @@ public class ScreenCastService extends Service {
mainHandler.post(() -> {
try {
startCaptureOnMainThread(resultCode, data, settings);
} catch (IOException e) {
error.set(e);
} catch (Exception e) {
if (e instanceof IOException) {
error.set((IOException) e);
} else {
error.set(new IOException(e.getMessage() != null ? e.getMessage() : e.toString(), e));
}
} finally {
latch.countDown();
}
@@ -339,8 +349,9 @@ public class ScreenCastService extends Service {
@Override
public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
sendSafe(CastProtocol.MSG_VIDEO_FRAME,
() -> CastProtocol.videoFramePayload(ptsUs, keyFrame, frameData));
if (sendPump != null) {
sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
}
}
};
@@ -421,22 +432,15 @@ public class ScreenCastService extends Service {
}
}
private void send(byte type, byte[] payloadBytes) throws IOException {
if (stopping.get() || session == null) {
return;
}
session.send(type, payloadBytes);
}
private void sendSafe(byte type, PayloadBuilder builder) {
if (stopping.get()) {
if (stopping.get() || sendPump == null) {
return;
}
try {
send(type, builder.build());
sendPump.enqueue(type, builder.build());
} catch (IOException e) {
if (!stopping.get()) {
Log.w(TAG, "Send failed: " + e.getMessage());
Log.w(TAG, "Payload build failed: " + e.getMessage());
stopping.set(true);
}
}
@@ -484,6 +488,10 @@ public class ScreenCastService extends Service {
projection.stop();
projection = null;
}
if (sendPump != null) {
sendPump.stop();
sendPump = null;
}
if (session != null) {
try {
session.send(CastProtocol.MSG_GOODBYE, new byte[0]);

View File

@@ -3,6 +3,7 @@ package com.foxx.androidcast.sender;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Surface;
@@ -13,7 +14,7 @@ import com.foxx.androidcast.CastSettings;
import java.io.IOException;
import java.nio.ByteBuffer;
/** H.264 encoder using MediaCodec (AVC). */
/** H.264 encoder using MediaCodec (AVC), tuned for low-latency screen cast. */
public class VideoEncoder {
private static final String TAG = "VideoEncoder";
private static final String MIME = MediaFormat.MIMETYPE_VIDEO_AVC;
@@ -37,22 +38,67 @@ public class VideoEncoder {
this.height = height;
this.callback = callback;
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate());
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps());
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL);
MediaFormat tuned = buildFormat(width, height, settings, true);
MediaFormat basic = buildFormat(width, height, settings, false);
codec = MediaCodec.createEncoderByType(MIME);
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
if (!tryConfigure(codec, tuned)) {
Log.w(TAG, "Tuned encoder format rejected; retrying with basic format");
codec.release();
codec = MediaCodec.createEncoderByType(MIME);
if (!tryConfigure(codec, basic)) {
codec.release();
codec = null;
throw new IOException("H.264 encoder configure failed for " + width + "x" + height);
}
}
inputSurface = codec.createInputSurface();
codec.start();
running = true;
drainThread = new Thread(this::drainLoop, "VideoEncoderDrain");
drainThread.start();
Log.i(TAG, "Encoder " + width + "x" + height + " @ " + settings.getVideoFps() + "fps "
+ (settings.getVideoBitrate() / 1000) + "kbps");
return inputSurface;
}
private static MediaFormat buildFormat(int width, int height, CastSettings settings, boolean tuned) {
MediaFormat format = MediaFormat.createVideoFormat(MIME, width, height);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_BIT_RATE, settings.getVideoBitrate());
format.setInteger(MediaFormat.KEY_FRAME_RATE, settings.getVideoFps());
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, CastConfig.VIDEO_I_FRAME_INTERVAL);
if (!tuned) {
return format;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
format.setInteger(MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.setInteger(MediaFormat.KEY_PRIORITY, 0);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
format.setInteger(MediaFormat.KEY_OPERATING_RATE, settings.getVideoFps());
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
}
return format;
}
private static boolean tryConfigure(MediaCodec codec, MediaFormat format) {
try {
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
Log.i(TAG, "Using encoder format: " + format);
return true;
} catch (IllegalArgumentException | IllegalStateException e) {
Log.w(TAG, "Encoder configure rejected: " + format + "" + e.getMessage());
return false;
}
}
public void requestKeyframe() {
if (codec == null) {
return;
@@ -93,7 +139,7 @@ public class VideoEncoder {
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean configSent = false;
while (running) {
int index = codec.dequeueOutputBuffer(info, 10_000);
int index = codec.dequeueOutputBuffer(info, 5_000);
if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {
continue;
}

View File

@@ -22,9 +22,9 @@
<string name="continue_video_only">Continue (video only)</string>
<string name="transport_tcp">TCP (reliable)</string>
<string name="transport_udp">UDP (best effort)</string>
<string name="quality_low">Low (~0.8 Mbps)</string>
<string name="quality_medium">Medium (~2 Mbps)</string>
<string name="quality_high">High (~4 Mbps)</string>
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
<string name="quality_medium">Medium (~5 Mbps, 30 fps) — recommended</string>
<string name="quality_high">High (~10 Mbps, 30 fps)</string>
<string name="resolution_full">Full display</string>
<string name="resolution_75">75% scale</string>
<string name="resolution_half">50% scale</string>
@@ -56,6 +56,8 @@
<string name="error_projection_data">Screen capture data missing (retry cast)</string>
<string name="error_missing_params">Missing host, PIN, or settings</string>
<string name="error_no_intent">No cast intent</string>
<string name="error_receiver_refused">Receiver not listening at %1$s:%2$d — on the tablet tap Start listening, then Refresh on this phone.</string>
<string name="error_connect_failed">Could not connect to %1$s:%2$d</string>
<string name="sender_check_notification">Cast starting — see notification for status</string>
<string name="playback_opened">Fullscreen playback %1$dx%2$d</string>
<string name="playback_rendering">Video playing</string>