diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e42917a..4245e6c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,6 +16,7 @@
+
+
> T readEnum(SharedPreferences p, String key, T[] values, T fallback) {
+ String name = p.getString(key, fallback.name());
+ for (T v : values) {
+ if (v.name().equals(name)) {
+ return v;
+ }
+ }
+ return fallback;
+ }
+
+ private static SharedPreferences prefs(Context context) {
+ return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/CastActiveState.java b/app/src/main/java/com/foxx/androidcast/CastActiveState.java
index fd8e798..9db3e4b 100644
--- a/app/src/main/java/com/foxx/androidcast/CastActiveState.java
+++ b/app/src/main/java/com/foxx/androidcast/CastActiveState.java
@@ -5,6 +5,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
/** Process-wide cast session flags for UI back-key handling. */
public final class CastActiveState {
private static final AtomicBoolean senderCasting = new AtomicBoolean(false);
+ private static final AtomicBoolean receiverListening = new AtomicBoolean(false);
private CastActiveState() {}
@@ -15,4 +16,12 @@ public final class CastActiveState {
public static boolean isSenderCasting() {
return senderCasting.get();
}
+
+ public static void setReceiverListening(boolean active) {
+ receiverListening.set(active);
+ }
+
+ public static boolean isReceiverListening() {
+ return receiverListening.get();
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java b/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java
new file mode 100644
index 0000000..568d3ab
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java
@@ -0,0 +1,28 @@
+package com.foxx.androidcast;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+
+/** Remembers the last foreground activity for tray / notification resume. */
+public final class CastActivityTracker {
+ private static volatile Class extends Activity> lastActivityClass;
+
+ private CastActivityTracker() {}
+
+ public static void onResume(Activity activity) {
+ if (activity != null) {
+ lastActivityClass = activity.getClass();
+ }
+ }
+
+ public static Intent resumeIntent(Context context) {
+ Class extends Activity> cls = lastActivityClass;
+ if (cls != null) {
+ Intent intent = new Intent(context, cls);
+ intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ return intent;
+ }
+ return new Intent(context, MainActivity.class);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/CastNotifications.java b/app/src/main/java/com/foxx/androidcast/CastNotifications.java
index 4451ba3..6d551f4 100644
--- a/app/src/main/java/com/foxx/androidcast/CastNotifications.java
+++ b/app/src/main/java/com/foxx/androidcast/CastNotifications.java
@@ -42,9 +42,9 @@ public final class CastNotifications {
private static Notification build(
Service service, String channel, int id, String text,
Class> serviceClass, String stopAction) {
- Intent open = new Intent(service, MainActivity.class);
PendingIntent pi = PendingIntent.getActivity(
- service, 0, open, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ service, 0, CastActivityTracker.resumeIntent(service),
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Intent stop = new Intent(service, serviceClass);
stop.setAction(stopAction);
PendingIntent stopPi = PendingIntent.getService(
diff --git a/app/src/main/java/com/foxx/androidcast/CastSettings.java b/app/src/main/java/com/foxx/androidcast/CastSettings.java
index 7f13351..87d6563 100644
--- a/app/src/main/java/com/foxx/androidcast/CastSettings.java
+++ b/app/src/main/java/com/foxx/androidcast/CastSettings.java
@@ -5,7 +5,7 @@ import java.io.Serializable;
/** User-selected cast parameters (passed sender → service → receiver via protocol). */
public class CastSettings implements Serializable {
public static final String EXTRA = "cast_settings";
- public static final int SETTINGS_VERSION = 3;
+ public static final int SETTINGS_VERSION = 4;
public enum Quality {
LOW(1_500_000, 24),
@@ -49,11 +49,25 @@ public class CastSettings implements Serializable {
USER_CHOICE,
/** Generated TV calibration pattern (no MediaProjection). */
CALIBRATION_TEST,
- /**
- * Future: Camera2 preview as video source (not MediaProjection app picker).
- * {@link PlatformCastSupport#isCameraCaptureModeSupported()}
- */
- CAMERA
+ /** Camera2 rear camera as video source. */
+ CAMERA,
+ /** Camera2 front camera as video source. */
+ CAMERA_FRONT
+ }
+
+ /** UDP stream protection hint (negotiated with peer; stubs until implemented). */
+ public enum StreamProtection {
+ AUTO,
+ FEC_3_4,
+ FEC_REED_SOLOMON,
+ NACK,
+ FEC_NACK
+ }
+
+ /** How the receiver fits incoming video on its display. */
+ public enum ReceiverDisplayResolution {
+ AUTO_FIT,
+ ADAPTIVE_EXPERIMENTAL
}
public enum Resolution {
@@ -78,13 +92,15 @@ public class CastSettings implements Serializable {
private String transport = CastConfig.DEFAULT_TRANSPORT;
private Quality quality = Quality.MEDIUM;
- private Resolution resolution = Resolution.HD_720P;
+ private Resolution resolution = Resolution.ADAPTIVE;
private boolean audioEnabled;
private VideoCodec videoCodec = VideoCodec.AUTO;
private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO;
private BitrateMode bitrateMode = BitrateMode.AUTO;
private NetworkAdoption networkAdoption = NetworkAdoption.AUTO;
private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode();
+ private StreamProtection streamProtection = StreamProtection.AUTO;
+ private ReceiverDisplayResolution receiverDisplayResolution = ReceiverDisplayResolution.AUTO_FIT;
/** Filled after codec handshake (e.g. video/avc). */
private transient String negotiatedVideoMime;
@@ -200,4 +216,47 @@ public class CastSettings implements Serializable {
public void setCaptureMode(CaptureMode captureMode) {
this.captureMode = captureMode != null ? captureMode : CaptureMode.FULL_SCREEN;
}
+
+ public StreamProtection getStreamProtection() {
+ return streamProtection;
+ }
+
+ public void setStreamProtection(StreamProtection streamProtection) {
+ this.streamProtection = streamProtection != null ? streamProtection : StreamProtection.AUTO;
+ }
+
+ public ReceiverDisplayResolution getReceiverDisplayResolution() {
+ return receiverDisplayResolution;
+ }
+
+ public void setReceiverDisplayResolution(ReceiverDisplayResolution receiverDisplayResolution) {
+ this.receiverDisplayResolution = receiverDisplayResolution != null
+ ? receiverDisplayResolution : ReceiverDisplayResolution.AUTO_FIT;
+ }
+
+ public boolean isCameraCapture() {
+ return captureMode == CaptureMode.CAMERA || captureMode == CaptureMode.CAMERA_FRONT;
+ }
+
+ public boolean isFrontCamera() {
+ return captureMode == CaptureMode.CAMERA_FRONT;
+ }
+
+ public void copyFrom(CastSettings other) {
+ if (other == null) {
+ return;
+ }
+ transport = other.transport;
+ quality = other.quality;
+ resolution = other.resolution;
+ audioEnabled = other.audioEnabled;
+ videoCodec = other.videoCodec;
+ adjustmentPreset = other.adjustmentPreset;
+ bitrateMode = other.bitrateMode;
+ networkAdoption = other.networkAdoption;
+ captureMode = other.captureMode;
+ streamProtection = other.streamProtection;
+ receiverDisplayResolution = other.receiverDisplayResolution;
+ negotiatedVideoMime = other.negotiatedVideoMime;
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/CastSettingsActivity.java
new file mode 100644
index 0000000..c24599c
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/CastSettingsActivity.java
@@ -0,0 +1,66 @@
+package com.foxx.androidcast;
+
+import android.os.Bundle;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.foxx.androidcast.receiver.ReceiverPrefs;
+
+/** Global, sender, and receiver default cast settings. */
+public class CastSettingsActivity extends AppCompatActivity {
+ private final CastSettings senderSettings = new CastSettings();
+ private final CastSettings receiverSettings = new CastSettings();
+
+ private EditText usernameInput;
+ private EditText pinInput;
+ private CheckBox trayIconCheck;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_cast_settings);
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ usernameInput = findViewById(R.id.edit_username);
+ pinInput = findViewById(R.id.edit_settings_pin);
+ trayIconCheck = findViewById(R.id.check_tray_icon);
+
+ senderSettings.copyFrom(AppPreferences.loadSenderDefaults(this));
+ receiverSettings.copyFrom(AppPreferences.loadReceiverDefaults(this));
+
+ CastSettingsBinder.bindGlobal(this, usernameInput, pinInput, trayIconCheck);
+ CastSettingsBinder.bindSender(this, senderSettings);
+ CastSettingsBinder.bindReceiver(this, receiverSettings);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ CastActivityTracker.onResume(this);
+ }
+
+ @Override
+ protected void onPause() {
+ saveAll();
+ super.onPause();
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ finish();
+ return true;
+ }
+
+ private void saveAll() {
+ CastSettingsBinder.saveGlobal(this, usernameInput, pinInput, trayIconCheck);
+ CastSettingsBinder.saveSender(this, senderSettings);
+ CastSettingsBinder.saveReceiver(this, receiverSettings);
+ ReceiverPrefs.setAllowAudio(this, AppPreferences.isPlayIncomingAudio(this));
+ Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java
new file mode 100644
index 0000000..9701e82
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java
@@ -0,0 +1,443 @@
+package com.foxx.androidcast;
+
+import android.os.Build;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.foxx.androidcast.sender.CodecCatalog;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Binds settings UI widgets to {@link AppPreferences} and {@link CastSettings}. */
+public final class CastSettingsBinder {
+ private CastSettingsBinder() {}
+
+ public static void bindGlobal(AppCompatActivity activity, EditText username, EditText pin,
+ CheckBox trayIcon) {
+ username.setText(AppPreferences.getUsername(activity));
+ pin.setText(AppPreferences.getPin(activity));
+ trayIcon.setChecked(AppPreferences.isShowTrayIcon(activity));
+ }
+
+ public static void saveGlobal(AppCompatActivity activity, EditText username, EditText pin,
+ CheckBox trayIcon) {
+ String name = username.getText().toString().trim();
+ if (name.isEmpty()) {
+ name = AppPreferences.defaultDeviceName(activity);
+ username.setText(name);
+ }
+ AppPreferences.setUsername(activity, name);
+ AppPreferences.setPin(activity, pin.getText().toString().trim());
+ AppPreferences.setShowTrayIcon(activity, trayIcon.isChecked());
+ }
+
+ public static void bindTheme(AppCompatActivity activity, Spinner spinner) {
+ CastThemeHelper.AppThemeMode[] modes = CastThemeHelper.AppThemeMode.values();
+ String[] labels = {
+ activity.getString(R.string.theme_system),
+ activity.getString(R.string.theme_dark),
+ activity.getString(R.string.theme_light),
+ activity.getString(R.string.theme_inverted)
+ };
+ bindEnumSpinner(spinner, modes, labels, AppPreferences.getThemeMode(activity),
+ mode -> AppPreferences.setThemeMode(activity, mode));
+ }
+
+ public static void saveTheme(AppCompatActivity activity, Spinner spinner) {
+ readEnumSpinner(spinner, (CastThemeHelper.AppThemeMode mode) ->
+ AppPreferences.setThemeMode(activity, mode));
+ }
+
+ public static CastThemeHelper.AppThemeMode themeModeAt(int position) {
+ CastThemeHelper.AppThemeMode[] modes = CastThemeHelper.AppThemeMode.values();
+ if (position >= 0 && position < modes.length) {
+ return modes[position];
+ }
+ return CastThemeHelper.AppThemeMode.SYSTEM;
+ }
+
+ public static void bindSender(AppCompatActivity activity, CastSettings settings) {
+ bindTransport(activity, settings, R.id.spinner_sender_transport, true);
+ bindVideoCodec(activity, settings, R.id.spinner_sender_video_codec);
+ bindBitrate(activity, settings);
+ bindNetworkAdoption(activity, settings, R.id.spinner_sender_network);
+ bindSenderResolution(activity, settings);
+ bindCaptureMode(activity, settings);
+ bindStreamProtection(activity, settings);
+ bindAudioSpinner(activity, settings, R.id.spinner_sender_audio);
+ }
+
+ public static void bindReceiver(AppCompatActivity activity, CastSettings settings) {
+ 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);
+ bindReceiverDisplay(activity, settings);
+ Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
+ if (audioPlay != null) {
+ bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity),
+ on -> AppPreferences.setPlayIncomingAudio(activity, on));
+ }
+ }
+
+ public static void saveSender(AppCompatActivity activity, CastSettings settings) {
+ readTransport(activity, settings, R.id.spinner_sender_transport);
+ readVideoCodec(activity, settings, R.id.spinner_sender_video_codec);
+ readBitrate(activity, settings);
+ readNetworkAdoption(activity, settings, R.id.spinner_sender_network);
+ readSenderResolution(activity, settings);
+ readCaptureMode(activity, settings);
+ readStreamProtection(activity, settings);
+ readAudioSpinner(activity, settings, R.id.spinner_sender_audio);
+ settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
+ AppPreferences.saveSenderDefaults(activity, settings);
+ }
+
+ public static void saveReceiver(AppCompatActivity activity, CastSettings settings) {
+ 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);
+ readReceiverDisplay(activity, settings);
+ Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
+ if (audioPlay != null) {
+ AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0);
+ }
+ settings.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
+ AppPreferences.saveReceiverDefaults(activity, settings);
+ }
+
+ private static void bindTransport(AppCompatActivity activity, CastSettings settings, int spinnerId,
+ boolean unused) {
+ Spinner spinner = activity.findViewById(spinnerId);
+ String[] labels = {
+ activity.getString(R.string.transport_udp),
+ activity.getString(R.string.transport_tcp),
+ activity.getString(R.string.transport_quic)
+ };
+ String[] values = {
+ CastConfig.TRANSPORT_UDP,
+ CastConfig.TRANSPORT_TCP,
+ CastConfig.TRANSPORT_QUIC
+ };
+ bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
+ settings.setTransport(v);
+ updateStreamProtectionEnabled(activity, settings);
+ });
+ updateStreamProtectionEnabled(activity, settings);
+ }
+
+ private static void readTransport(AppCompatActivity activity, CastSettings settings, int spinnerId) {
+ Spinner spinner = activity.findViewById(spinnerId);
+ Object tag = spinner.getTag(R.id.tag_spinner_values);
+ if (tag instanceof String[]) {
+ int pos = spinner.getSelectedItemPosition();
+ String[] values = (String[]) tag;
+ if (pos >= 0 && pos < values.length) {
+ settings.setTransport(values[pos]);
+ }
+ }
+ }
+
+ private static void bindVideoCodec(AppCompatActivity activity, CastSettings settings, int spinnerId) {
+ Spinner spinner = activity.findViewById(spinnerId);
+ List codecs = CodecCatalog.supportedChoices();
+ List labels = new ArrayList<>();
+ for (CastSettings.VideoCodec c : codecs) {
+ labels.add(labelVideoCodec(activity, c));
+ }
+ bindEnumSpinner(spinner, codecs.toArray(new CastSettings.VideoCodec[0]),
+ labels.toArray(new String[0]), settings.getVideoCodec(),
+ v -> settings.setVideoCodec(v));
+ }
+
+ private static void readVideoCodec(AppCompatActivity activity, CastSettings settings, int spinnerId) {
+ Spinner spinner = activity.findViewById(spinnerId);
+ Object tag = spinner.getTag(R.id.tag_spinner_enum);
+ if (tag instanceof CastSettings.VideoCodec[]) {
+ CastSettings.VideoCodec[] values = (CastSettings.VideoCodec[]) tag;
+ int pos = spinner.getSelectedItemPosition();
+ if (pos >= 0 && pos < values.length) {
+ settings.setVideoCodec(values[pos]);
+ }
+ }
+ }
+
+ private static void bindBitrate(AppCompatActivity activity, CastSettings settings) {
+ Spinner spinner = activity.findViewById(R.id.spinner_sender_bitrate);
+ CastSettings.BitrateMode[] modes = {
+ CastSettings.BitrateMode.ESTIMATED,
+ CastSettings.BitrateMode.QUALITY,
+ CastSettings.BitrateMode.SPEED,
+ CastSettings.BitrateMode.BALANCED
+ };
+ String[] labels = {
+ activity.getString(R.string.bitrate_estimated),
+ activity.getString(R.string.option_quality),
+ activity.getString(R.string.option_speed),
+ activity.getString(R.string.option_balanced)
+ };
+ CastSettings.BitrateMode current = settings.getBitrateMode();
+ if (current == CastSettings.BitrateMode.AUTO) {
+ current = CastSettings.BitrateMode.ESTIMATED;
+ }
+ bindEnumSpinner(spinner, modes, labels, current, settings::setBitrateMode);
+ }
+
+ private static void readBitrate(AppCompatActivity activity, CastSettings settings) {
+ readEnumSpinner(activity.findViewById(R.id.spinner_sender_bitrate), settings::setBitrateMode);
+ }
+
+ private static void bindNetworkAdoption(AppCompatActivity activity, CastSettings settings, int spinnerId) {
+ Spinner spinner = activity.findViewById(spinnerId);
+ CastSettings.NetworkAdoption[] modes = {
+ CastSettings.NetworkAdoption.ESTIMATED,
+ CastSettings.NetworkAdoption.QUALITY,
+ CastSettings.NetworkAdoption.SPEED
+ };
+ String[] labels = {
+ activity.getString(R.string.network_estimated_auto),
+ activity.getString(R.string.option_quality),
+ activity.getString(R.string.option_speed)
+ };
+ CastSettings.NetworkAdoption current = settings.getNetworkAdoption();
+ if (current == CastSettings.NetworkAdoption.AUTO) {
+ current = CastSettings.NetworkAdoption.ESTIMATED;
+ }
+ bindEnumSpinner(spinner, modes, labels, current, settings::setNetworkAdoption);
+ }
+
+ private static void readNetworkAdoption(AppCompatActivity activity, CastSettings settings, int spinnerId) {
+ readEnumSpinner(activity.findViewById(spinnerId), settings::setNetworkAdoption);
+ }
+
+ private static void bindSenderResolution(AppCompatActivity activity, CastSettings settings) {
+ Spinner spinner = activity.findViewById(R.id.spinner_sender_resolution);
+ CastSettings.Resolution[] modes = {
+ CastSettings.Resolution.ADAPTIVE,
+ CastSettings.Resolution.FIXED_1080P,
+ CastSettings.Resolution.FIXED_720P,
+ CastSettings.Resolution.FULL,
+ CastSettings.Resolution.THREE_QUARTERS
+ };
+ String[] labels = {
+ activity.getString(R.string.resolution_adaptive),
+ activity.getString(R.string.resolution_fixed_1080p),
+ activity.getString(R.string.resolution_fixed_720p),
+ activity.getString(R.string.resolution_full),
+ activity.getString(R.string.resolution_75)
+ };
+ bindEnumSpinner(spinner, modes, labels, settings.getResolution(), settings::setResolution);
+ }
+
+ private static void readSenderResolution(AppCompatActivity activity, CastSettings settings) {
+ readEnumSpinner(activity.findViewById(R.id.spinner_sender_resolution), settings::setResolution);
+ }
+
+ private static void bindReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
+ Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution);
+ CastSettings.ReceiverDisplayResolution[] modes = CastSettings.ReceiverDisplayResolution.values();
+ String[] labels = {
+ activity.getString(R.string.receiver_display_auto_fit),
+ activity.getString(R.string.receiver_display_adaptive)
+ };
+ bindEnumSpinner(spinner, modes, labels, settings.getReceiverDisplayResolution(),
+ settings::setReceiverDisplayResolution);
+ }
+
+ private static void readReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
+ readEnumSpinner(activity.findViewById(R.id.spinner_receiver_resolution),
+ settings::setReceiverDisplayResolution);
+ }
+
+ private static void bindCaptureMode(AppCompatActivity activity, CastSettings settings) {
+ Spinner spinner = activity.findViewById(R.id.spinner_sender_capture_mode);
+ List labels = new ArrayList<>();
+ List values = new ArrayList<>();
+ labels.add(activity.getString(R.string.capture_full_screen));
+ values.add(CastSettings.CaptureMode.FULL_SCREEN);
+ if (PlatformCastSupport.isCaptureUserChoiceSupported()) {
+ labels.add(activity.getString(R.string.capture_user_choice));
+ values.add(CastSettings.CaptureMode.USER_CHOICE);
+ }
+ if (PlatformCastSupport.isCameraCaptureModeSupported()) {
+ labels.add(activity.getString(R.string.capture_camera_rear));
+ values.add(CastSettings.CaptureMode.CAMERA);
+ if (PlatformCastSupport.hasFrontCamera(activity)) {
+ labels.add(activity.getString(R.string.capture_camera_front));
+ values.add(CastSettings.CaptureMode.CAMERA_FRONT);
+ }
+ }
+ labels.add(activity.getString(R.string.capture_calibration_test));
+ values.add(CastSettings.CaptureMode.CALIBRATION_TEST);
+ bindEnumSpinner(spinner, values.toArray(new CastSettings.CaptureMode[0]),
+ labels.toArray(new String[0]), settings.getCaptureMode(), settings::setCaptureMode);
+ }
+
+ private static void readCaptureMode(AppCompatActivity activity, CastSettings settings) {
+ readEnumSpinner(activity.findViewById(R.id.spinner_sender_capture_mode), settings::setCaptureMode);
+ }
+
+ private static void bindStreamProtection(AppCompatActivity activity, CastSettings settings) {
+ Spinner spinner = activity.findViewById(R.id.spinner_sender_stream_protection);
+ CastSettings.StreamProtection[] modes = CastSettings.StreamProtection.values();
+ String[] labels = {
+ activity.getString(R.string.option_auto),
+ activity.getString(R.string.protection_fec_34),
+ activity.getString(R.string.protection_fec_rs),
+ activity.getString(R.string.protection_nack),
+ activity.getString(R.string.protection_fec_nack)
+ };
+ bindEnumSpinner(spinner, modes, labels, settings.getStreamProtection(),
+ settings::setStreamProtection);
+ updateStreamProtectionEnabled(activity, settings);
+ }
+
+ private static void readStreamProtection(AppCompatActivity activity, CastSettings settings) {
+ readEnumSpinner(activity.findViewById(R.id.spinner_sender_stream_protection),
+ settings::setStreamProtection);
+ }
+
+ private static void updateStreamProtectionEnabled(AppCompatActivity activity, CastSettings settings) {
+ Spinner protection = activity.findViewById(R.id.spinner_sender_stream_protection);
+ if (protection == null) {
+ return;
+ }
+ boolean udp = CastConfig.TRANSPORT_UDP.equals(settings.getTransport());
+ protection.setEnabled(udp);
+ TextView hint = activity.findViewById(R.id.text_stream_protection_hint);
+ if (hint != null) {
+ hint.setVisibility(udp ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ private static void bindAudioSpinner(AppCompatActivity activity, CastSettings settings, int spinnerId) {
+ Spinner spinner = activity.findViewById(spinnerId);
+ TextView hint = activity.findViewById(R.id.text_sender_audio_hint);
+ if (!PlatformCastSupport.isAudioCaptureSupported()) {
+ spinner.setEnabled(false);
+ if (hint != null) {
+ hint.setVisibility(View.VISIBLE);
+ hint.setText(R.string.label_audio_unavailable);
+ }
+ settings.setAudioEnabled(false);
+ return;
+ }
+ if (hint != null) {
+ hint.setVisibility(View.GONE);
+ }
+ bindOnOffSpinner(activity, spinner, settings.isAudioRequested(),
+ settings::setAudioEnabled);
+ }
+
+ private static void readAudioSpinner(AppCompatActivity activity, CastSettings settings, int spinnerId) {
+ Spinner spinner = activity.findViewById(spinnerId);
+ if (!PlatformCastSupport.isAudioCaptureSupported()) {
+ settings.setAudioEnabled(false);
+ return;
+ }
+ settings.setAudioEnabled(spinner.getSelectedItemPosition() == 0);
+ }
+
+ private static void bindOnOffSpinner(AppCompatActivity activity, Spinner spinner, boolean on,
+ java.util.function.Consumer consumer) {
+ String[] labels = {activity.getString(R.string.option_on), activity.getString(R.string.option_off)};
+ spinner.setAdapter(new ArrayAdapter<>(activity, android.R.layout.simple_spinner_dropdown_item, labels));
+ spinner.setSelection(on ? 0 : 1);
+ spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ consumer.accept(position == 0);
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {}
+ });
+ }
+
+ private static void bindStringSpinner(Spinner spinner, String[] labels, String[] values,
+ String current, java.util.function.Consumer onSelect) {
+ spinner.setTag(R.id.tag_spinner_values, values);
+ spinner.setAdapter(new ArrayAdapter<>(spinner.getContext(),
+ android.R.layout.simple_spinner_dropdown_item, labels));
+ int sel = 0;
+ for (int i = 0; i < values.length; i++) {
+ if (values[i].equals(current)) {
+ sel = i;
+ break;
+ }
+ }
+ spinner.setSelection(sel);
+ spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ if (position >= 0 && position < values.length) {
+ onSelect.accept(values[position]);
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {}
+ });
+ }
+
+ private static > void bindEnumSpinner(Spinner spinner, T[] values, String[] labels,
+ T current, java.util.function.Consumer onSelect) {
+ spinner.setTag(R.id.tag_spinner_enum, values);
+ spinner.setAdapter(new ArrayAdapter<>(spinner.getContext(),
+ android.R.layout.simple_spinner_dropdown_item, labels));
+ int sel = 0;
+ for (int i = 0; i < values.length; i++) {
+ if (values[i] == current) {
+ sel = i;
+ break;
+ }
+ }
+ spinner.setSelection(sel);
+ spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ if (position >= 0 && position < values.length) {
+ onSelect.accept(values[position]);
+ }
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {}
+ });
+ }
+
+ private static > void readEnumSpinner(Spinner spinner,
+ java.util.function.Consumer consumer) {
+ Object tag = spinner.getTag(R.id.tag_spinner_enum);
+ if (tag instanceof Enum[]) {
+ Enum>[] values = (Enum>[]) tag;
+ int pos = spinner.getSelectedItemPosition();
+ if (pos >= 0 && pos < values.length) {
+ @SuppressWarnings("unchecked")
+ T v = (T) values[pos];
+ consumer.accept(v);
+ }
+ }
+ }
+
+ private static String labelVideoCodec(AppCompatActivity activity, CastSettings.VideoCodec codec) {
+ switch (codec) {
+ case H264:
+ return activity.getString(R.string.codec_h264);
+ case H265:
+ return activity.getString(R.string.codec_h265);
+ case PASSTHROUGH:
+ return activity.getString(R.string.codec_passthrough);
+ case AUTO:
+ default:
+ return activity.getString(R.string.option_auto);
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
new file mode 100644
index 0000000..fbe4afa
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
@@ -0,0 +1,105 @@
+package com.foxx.androidcast;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.Fragment;
+
+import com.foxx.androidcast.receiver.ReceiverPrefs;
+
+/** Settings drawer content — swipe from the left edge on main screens. */
+public class CastSettingsFragment extends Fragment {
+ private final CastSettings senderSettings = new CastSettings();
+ private final CastSettings receiverSettings = new CastSettings();
+
+ private EditText usernameInput;
+ private EditText pinInput;
+ private CheckBox trayIconCheck;
+ private Spinner themeSpinner;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_cast_settings, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ TextView headerGlobal = view.findViewById(R.id.header_global);
+ TextView headerSender = view.findViewById(R.id.header_sender);
+ TextView headerReceiver = view.findViewById(R.id.header_receiver);
+ headerGlobal.setText(R.string.settings_section_global);
+ headerSender.setText(R.string.settings_section_sender);
+ headerReceiver.setText(R.string.settings_section_receiver);
+
+ usernameInput = view.findViewById(R.id.edit_username);
+ pinInput = view.findViewById(R.id.edit_settings_pin);
+ trayIconCheck = view.findViewById(R.id.check_tray_icon);
+ themeSpinner = view.findViewById(R.id.spinner_theme);
+
+ senderSettings.copyFrom(AppPreferences.loadSenderDefaults(requireContext()));
+ receiverSettings.copyFrom(AppPreferences.loadReceiverDefaults(requireContext()));
+
+ AppCompatActivity activity = (AppCompatActivity) requireActivity();
+ CastSettingsBinder.bindGlobal(activity, usernameInput, pinInput, trayIconCheck);
+ CastSettingsBinder.bindTheme(activity, themeSpinner);
+ CastSettingsBinder.bindSender(activity, senderSettings);
+ CastSettingsBinder.bindReceiver(activity, receiverSettings);
+
+ trayIconCheck.setOnCheckedChangeListener((btn, checked) -> {
+ AppPreferences.setShowTrayIcon(requireContext(), checked);
+ CastTrayNotifier.onPreferenceChanged(requireContext());
+ });
+ themeSpinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
+ private boolean first = true;
+
+ @Override
+ public void onItemSelected(android.widget.AdapterView> parent, View v, int position, long id) {
+ CastThemeHelper.AppThemeMode mode = CastSettingsBinder.themeModeAt(position);
+ if (first) {
+ first = false;
+ return;
+ }
+ if (mode != AppPreferences.getThemeMode(requireContext())) {
+ AppPreferences.setThemeMode(requireContext(), mode);
+ Toast.makeText(requireContext(), R.string.theme_apply_restart, Toast.LENGTH_LONG).show();
+ requireActivity().recreate();
+ }
+ }
+
+ @Override
+ public void onNothingSelected(android.widget.AdapterView> parent) {}
+ });
+ }
+
+ @Override
+ public void onPause() {
+ saveAll();
+ super.onPause();
+ }
+
+ private void saveAll() {
+ if (usernameInput == null) {
+ return;
+ }
+ AppCompatActivity activity = (AppCompatActivity) requireActivity();
+ CastSettingsBinder.saveGlobal(activity, usernameInput, pinInput, trayIconCheck);
+ CastSettingsBinder.saveTheme(activity, themeSpinner);
+ CastSettingsBinder.saveSender(activity, senderSettings);
+ CastSettingsBinder.saveReceiver(activity, receiverSettings);
+ ReceiverPrefs.setAllowAudio(requireContext(), AppPreferences.isPlayIncomingAudio(requireContext()));
+ CastTrayNotifier.onPreferenceChanged(requireContext());
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/CastThemeHelper.java b/app/src/main/java/com/foxx/androidcast/CastThemeHelper.java
new file mode 100644
index 0000000..28c90e0
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/CastThemeHelper.java
@@ -0,0 +1,57 @@
+package com.foxx.androidcast;
+
+import android.app.Activity;
+import android.content.Context;
+
+import androidx.appcompat.app.AppCompatDelegate;
+
+/** Applies user-selected app theme before {@link Activity#setContentView}. */
+public final class CastThemeHelper {
+ private CastThemeHelper() {}
+
+ public enum AppThemeMode {
+ SYSTEM,
+ DARK,
+ LIGHT,
+ INVERTED
+ }
+
+ public static void applyTheme(Activity activity) {
+ AppThemeMode mode = AppPreferences.getThemeMode(activity);
+ switch (mode) {
+ case DARK:
+ activity.setTheme(R.style.Theme_AndroidCast_Dark);
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+ break;
+ case LIGHT:
+ activity.setTheme(R.style.Theme_AndroidCast_Light);
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
+ break;
+ case INVERTED:
+ activity.setTheme(R.style.Theme_AndroidCast_Inverted);
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
+ break;
+ case SYSTEM:
+ default:
+ activity.setTheme(R.style.Theme_AndroidCast);
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
+ break;
+ }
+ }
+
+ public static void applyNightMode(Context context) {
+ switch (AppPreferences.getThemeMode(context)) {
+ case DARK:
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+ break;
+ case LIGHT:
+ case INVERTED:
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
+ break;
+ case SYSTEM:
+ default:
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
+ break;
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java
new file mode 100644
index 0000000..613033a
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java
@@ -0,0 +1,101 @@
+package com.foxx.androidcast;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.os.Build;
+
+import androidx.core.app.NotificationCompat;
+
+/**
+ * Persistent status-bar icon while cast services run (when enabled in settings).
+ */
+public final class CastTrayNotifier {
+ public static final String CHANNEL_TRAY = "cast_tray";
+ public static final int ID_TRAY = 3;
+
+ private CastTrayNotifier() {}
+
+ /** Sync tray with cast service lifecycle (call from foreground services). */
+ public static void refresh(Context context) {
+ Context app = context.getApplicationContext();
+ if (!AppPreferences.isShowTrayIcon(app)) {
+ hide(app);
+ return;
+ }
+ if (CastActiveState.isSenderCasting() || CastActiveState.isReceiverListening()) {
+ show(app, false);
+ } else {
+ hide(app);
+ }
+ }
+
+ /** User toggled the preference — show or hide immediately. */
+ public static void onPreferenceChanged(Context context) {
+ Context app = context.getApplicationContext();
+ if (AppPreferences.isShowTrayIcon(app)) {
+ show(app, true);
+ } else {
+ hide(app);
+ }
+ }
+
+ private static void show(Context context, boolean idle) {
+ createChannel(context);
+ NotificationManager nm = context.getSystemService(NotificationManager.class);
+ if (nm == null) {
+ return;
+ }
+ String body = idle
+ ? context.getString(R.string.tray_cast_idle)
+ : context.getString(R.string.tray_cast_active);
+ PendingIntent pi = PendingIntent.getActivity(
+ context, 0, CastActivityTracker.resumeIntent(context),
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ Notification n = new NotificationCompat.Builder(context, CHANNEL_TRAY)
+ .setContentTitle(context.getString(R.string.app_name))
+ .setContentText(body)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentIntent(pi)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setShowWhen(false)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .build();
+ nm.notify(ID_TRAY, n);
+ }
+
+ private static void hide(Context context) {
+ NotificationManager nm = context.getSystemService(NotificationManager.class);
+ if (nm != null) {
+ nm.cancel(ID_TRAY);
+ }
+ }
+
+ private static void createChannel(Context context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return;
+ }
+ NotificationManager nm = context.getSystemService(NotificationManager.class);
+ if (nm == null) {
+ return;
+ }
+ NotificationChannel existing = nm.getNotificationChannel(CHANNEL_TRAY);
+ if (existing != null && existing.getImportance() < NotificationManager.IMPORTANCE_DEFAULT) {
+ nm.deleteNotificationChannel(CHANNEL_TRAY);
+ }
+ if (nm.getNotificationChannel(CHANNEL_TRAY) == null) {
+ NotificationChannel ch = new NotificationChannel(
+ CHANNEL_TRAY,
+ context.getString(R.string.tray_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT);
+ ch.setDescription(context.getString(R.string.tray_channel_description));
+ ch.setShowBadge(true);
+ nm.createNotificationChannel(ch);
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java b/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java
new file mode 100644
index 0000000..ed0f554
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java
@@ -0,0 +1,69 @@
+package com.foxx.androidcast;
+
+import android.os.Bundle;
+import android.view.Gravity;
+import android.view.View;
+
+import androidx.activity.OnBackPressedCallback;
+import androidx.annotation.LayoutRes;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.view.GravityCompat;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.fragment.app.Fragment;
+
+/**
+ * Hosts main content and a start-edge settings drawer (swipe right from left edge to open).
+ */
+public abstract class DrawerHostActivity extends AppCompatActivity {
+ private DrawerLayout drawerLayout;
+
+ protected void applyActivityTheme() {
+ CastThemeHelper.applyTheme(this);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ applyActivityTheme();
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_drawer_host);
+
+ drawerLayout = findViewById(R.id.drawer_layout);
+ android.view.ViewGroup contentRoot = findViewById(R.id.content_root);
+ getLayoutInflater().inflate(getContentLayoutId(), contentRoot, true);
+
+ if (savedInstanceState == null) {
+ getSupportFragmentManager().beginTransaction()
+ .replace(R.id.settings_drawer_container, new CastSettingsFragment())
+ .commit();
+ }
+
+ drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED);
+ getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
+ @Override
+ public void handleOnBackPressed() {
+ if (drawerLayout.isDrawerOpen(GravityCompat.START)) {
+ drawerLayout.closeDrawer(GravityCompat.START);
+ } else {
+ setEnabled(false);
+ getOnBackPressedDispatcher().onBackPressed();
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ CastActivityTracker.onResume(this);
+ }
+
+ protected void openSettingsDrawer() {
+ drawerLayout.openDrawer(GravityCompat.START);
+ }
+
+ protected void closeSettingsDrawer() {
+ drawerLayout.closeDrawer(GravityCompat.START);
+ }
+
+ protected abstract @LayoutRes int getContentLayoutId();
+}
diff --git a/app/src/main/java/com/foxx/androidcast/MainActivity.java b/app/src/main/java/com/foxx/androidcast/MainActivity.java
index 9d5976e..181a94d 100644
--- a/app/src/main/java/com/foxx/androidcast/MainActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/MainActivity.java
@@ -4,27 +4,30 @@ import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
-import androidx.appcompat.app.AppCompatActivity;
-
-import com.foxx.androidcast.receiver.ReceiverActivity;
+import com.foxx.androidcast.receiver.ReceiverPlaybackActivity;
import com.foxx.androidcast.sender.SenderActivity;
import com.foxx.androidcast.stats.SessionStatsStore;
-public class MainActivity extends AppCompatActivity {
+public class MainActivity extends DrawerHostActivity {
+ @Override
+ protected int getContentLayoutId() {
+ return R.layout.activity_main;
+ }
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
SessionStatsStore.pruneOnAppStart(this);
- setContentView(R.layout.activity_main);
com.foxx.androidcast.network.CastTransportFactory.init(this);
PermissionHelper.request(this);
- Button sendBtn = findViewById(R.id.btn_send);
- Button recvBtn = findViewById(R.id.btn_receive);
- sendBtn.setOnClickListener(v ->
+ findViewById(R.id.btn_send).setOnClickListener(v ->
startActivity(new Intent(this, SenderActivity.class)));
- recvBtn.setOnClickListener(v ->
- startActivity(new Intent(this, ReceiverActivity.class)));
+ findViewById(R.id.btn_receive).setOnClickListener(v -> {
+ Intent i = new Intent(this, ReceiverPlaybackActivity.class);
+ i.putExtra(ReceiverPlaybackActivity.EXTRA_AUTO_START_LISTENING, true);
+ startActivity(i);
+ });
}
@Override
diff --git a/app/src/main/java/com/foxx/androidcast/PinAuth.java b/app/src/main/java/com/foxx/androidcast/PinAuth.java
new file mode 100644
index 0000000..9277bc9
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/PinAuth.java
@@ -0,0 +1,38 @@
+package com.foxx.androidcast;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/** PIN hashing for discovery and handshake (never send raw PIN on LAN). */
+public final class PinAuth {
+ private PinAuth() {}
+
+ public static String hash(String pin) {
+ if (pin == null) {
+ pin = "";
+ }
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(pin.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b & 0xff));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ return Integer.toHexString(pin.hashCode());
+ }
+ }
+
+ public static boolean matches(String pin, String expectedHash) {
+ if (expectedHash == null || expectedHash.isEmpty()) {
+ return true;
+ }
+ return hash(pin).equals(expectedHash);
+ }
+
+ public static boolean isProtected(String pin) {
+ return pin != null && !pin.isEmpty();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java
index f347fce..69b31b8 100644
--- a/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java
+++ b/app/src/main/java/com/foxx/androidcast/PlatformCastSupport.java
@@ -32,6 +32,29 @@ public final class PlatformCastSupport {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
}
+ public static boolean hasFrontCamera(android.content.Context context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ return false;
+ }
+ try {
+ android.hardware.camera2.CameraManager mgr =
+ context.getSystemService(android.hardware.camera2.CameraManager.class);
+ if (mgr == null) {
+ return false;
+ }
+ for (String id : mgr.getCameraIdList()) {
+ android.hardware.camera2.CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
+ Integer facing = chars.get(android.hardware.camera2.CameraCharacteristics.LENS_FACING);
+ if (facing != null
+ && facing == android.hardware.camera2.CameraCharacteristics.LENS_FACING_FRONT) {
+ return true;
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ return false;
+ }
+
/** Applies default audio flag: on for Android 10+, off otherwise. */
public static void applyDefaultAudioSetting(CastSettings settings) {
settings.setAudioEnabled(isAudioCaptureSupported());
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 5759125..5372757 100644
--- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java
+++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java
@@ -8,6 +8,7 @@ import android.util.Log;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.DeviceInfo;
+import com.foxx.androidcast.PinAuth;
import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.CastPacketFramer;
import com.foxx.androidcast.network.CastProtoHeader;
@@ -53,13 +54,20 @@ public class DiscoveryManager {
public final int audioCodecs;
public final int placeholderId;
public final long protoVersion;
+ /** SHA-256 hex of receiver PIN, empty if anonymous. */
+ public final String pinAuth;
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
- this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L);
+ this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "");
}
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
int videoCodecs, int audioCodecs, int placeholderId, long protoVersion) {
+ this(name, host, port, transport, lastSeenMs, videoCodecs, audioCodecs, placeholderId, protoVersion, "");
+ }
+
+ public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
+ int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth) {
this.name = name;
this.host = host;
this.port = port;
@@ -69,6 +77,11 @@ public class DiscoveryManager {
this.audioCodecs = audioCodecs;
this.placeholderId = placeholderId;
this.protoVersion = protoVersion;
+ this.pinAuth = pinAuth != null ? pinAuth : "";
+ }
+
+ public boolean pinMatches(String pin) {
+ return PinAuth.matches(pin, pinAuth);
}
}
@@ -133,14 +146,19 @@ public class DiscoveryManager {
public void startAnnouncing(Context context, String deviceName, int port, String transport,
boolean listening) {
+ startAnnouncing(context, deviceName, port, transport, listening, "");
+ }
+
+ public void startAnnouncing(Context context, String deviceName, int port, String transport,
+ boolean listening, String pin) {
if (!running.compareAndSet(false, true)) {
return;
}
- executor.execute(() -> announceLoop(context, deviceName, port, transport, listening));
+ executor.execute(() -> announceLoop(context, deviceName, port, transport, listening, pin));
}
public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
- startAnnouncing(appContext, deviceName, port, transport, listening);
+ startAnnouncing(appContext, deviceName, port, transport, listening, "");
}
private void browseLoop() {
@@ -203,7 +221,7 @@ public class DiscoveryManager {
}
private void announceLoop(Context context, String deviceName, int port, String transport,
- boolean listening) {
+ boolean listening, String pin) {
acquireMulticastLock();
int placeholderId = DeviceInfo.getPlaceholderId(context);
int videoMask = CastCodecFlags.localDecoderVideoMask();
@@ -213,7 +231,7 @@ public class DiscoveryManager {
sock.setBroadcast(true);
sock.setSoTimeout(400);
while (running.get()) {
- sendAnnounce(sock, context, deviceName, port, transport, listening,
+ sendAnnounce(sock, context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask);
long listenUntil = System.currentTimeMillis() + 2_000;
while (running.get() && System.currentTimeMillis() < listenUntil) {
@@ -222,9 +240,9 @@ public class DiscoveryManager {
DatagramPacket packet = new DatagramPacket(buf, buf.length);
sock.receive(packet);
if (isDiscoverProbe(packet)) {
- sendAnnounce(sock, context, deviceName, port, transport, listening,
+ sendAnnounce(sock, context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask);
- sendAnnounceUnicast(sock, context, deviceName, port, transport, listening,
+ sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask, packet.getAddress());
}
} catch (java.net.SocketTimeoutException ignored) {
@@ -269,9 +287,9 @@ public class DiscoveryManager {
}
private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port,
- String transport, boolean listening, int placeholderId, int videoMask, int audioMask)
+ String transport, boolean listening, String pin, int placeholderId, int videoMask, int audioMask)
throws Exception {
- byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
+ byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask);
InetAddress broadcast = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
@@ -279,10 +297,10 @@ public class DiscoveryManager {
}
private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port,
- String transport, boolean listening, int placeholderId, int videoMask, int audioMask,
+ String transport, boolean listening, String pin, int placeholderId, int videoMask, int audioMask,
InetAddress target) {
try {
- byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening,
+ byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask);
DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT);
sock.send(packet);
@@ -292,7 +310,7 @@ public class DiscoveryManager {
}
private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport,
- boolean listening, int placeholderId, int videoMask, int audioMask) throws Exception {
+ boolean listening, String pin, int placeholderId, int videoMask, int audioMask) throws Exception {
JSONObject json = new JSONObject();
json.put("magic", CastConfig.DISCOVERY_MAGIC);
json.put("name", deviceName);
@@ -300,6 +318,9 @@ public class DiscoveryManager {
json.put("transport", transport);
json.put("listening", listening);
json.put("device_uuid", DeviceInfo.getDeviceUuid(context));
+ if (PinAuth.isProtected(pin)) {
+ json.put("pin_auth", PinAuth.hash(pin));
+ }
byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
try {
@@ -364,9 +385,10 @@ public class DiscoveryManager {
}
return;
}
+ String pinAuth = json.optString("pin_auth", "");
String key = host + ":" + port;
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport,
- System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion);
+ System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion, pinAuth);
devices.put(key, device);
notifyListener();
} catch (Exception ignored) {
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
index d9a6b52..e7bc4e5 100644
--- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
+++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java
@@ -80,6 +80,8 @@ public final class CastProtocol {
dos.writeInt(settings.getBitrateMode().ordinal());
dos.writeInt(settings.getNetworkAdoption().ordinal());
dos.writeInt(settings.getCaptureMode().ordinal());
+ dos.writeInt(settings.getStreamProtection().ordinal());
+ dos.writeInt(settings.getReceiverDisplayResolution().ordinal());
dos.flush();
return bos.toByteArray();
}
@@ -115,6 +117,15 @@ public final class CastProtocol {
settings.setCaptureMode(readEnum(CastSettings.CaptureMode.values(), dis.readInt(),
CastSettings.CaptureMode.FULL_SCREEN));
}
+ if (dis.available() > 0) {
+ settings.setStreamProtection(readEnum(CastSettings.StreamProtection.values(), dis.readInt(),
+ CastSettings.StreamProtection.AUTO));
+ }
+ if (dis.available() > 0) {
+ settings.setReceiverDisplayResolution(
+ readEnum(CastSettings.ReceiverDisplayResolution.values(), dis.readInt(),
+ CastSettings.ReceiverDisplayResolution.AUTO_FIT));
+ }
} else {
dis.reset();
settings.setTransport(dis.readUTF());
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
index 965567b..3b27eb7 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverActivity.java
@@ -8,31 +8,25 @@ import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
-import android.text.TextUtils;
import android.view.WindowManager;
import android.widget.Button;
-import android.widget.CheckBox;
-import android.widget.EditText;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
-import com.foxx.androidcast.CastConfig;
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.CastActivityTracker;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.R;
-import com.foxx.androidcast.SettingsUi;
-/** Receiver setup — listening runs in background; fullscreen playback opens when video stream starts. */
+/** Receiver setup — options live in {@link com.foxx.androidcast.CastSettingsActivity}. */
public class ReceiverActivity extends AppCompatActivity {
private final CastSettings castSettings = new CastSettings();
private TextView statusText;
- private EditText pinInput;
- private CheckBox allowAudioCheck;
-
private ICastReceiverService receiverService;
private boolean bound;
@@ -93,19 +87,20 @@ public class ReceiverActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_receiver);
PermissionHelper.request(this);
- SettingsUi.bindReceiver(this, castSettings);
+ castSettings.copyFrom(AppPreferences.loadReceiverDefaults(this));
statusText = findViewById(R.id.text_status);
- pinInput = findViewById(R.id.edit_pin);
- pinInput.setText(CastConfig.DEFAULT_PIN);
- allowAudioCheck = findViewById(R.id.check_allow_audio);
- allowAudioCheck.setChecked(ReceiverPrefs.isAllowAudio(this));
- allowAudioCheck.setOnCheckedChangeListener((btn, checked) ->
- ReceiverPrefs.setAllowAudio(ReceiverActivity.this, checked));
-
findViewById(R.id.btn_start_receiver).setOnClickListener(v -> startReceiverService());
}
+ @Override
+ protected void onResume() {
+ super.onResume();
+ CastActivityTracker.onResume(this);
+ PermissionHelper.remindIfMissing(this, AppPreferences.isPlayIncomingAudio(this));
+ updateKeepScreenOn();
+ }
+
@Override
protected void onStart() {
super.onStart();
@@ -126,13 +121,6 @@ public class ReceiverActivity extends AppCompatActivity {
super.onStop();
}
- @Override
- protected void onResume() {
- super.onResume();
- PermissionHelper.remindIfMissing(this, allowAudioCheck.isChecked());
- updateKeepScreenOn();
- }
-
@Override
protected void onPause() {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
@@ -148,11 +136,8 @@ public class ReceiverActivity extends AppCompatActivity {
}
private void startReceiverService() {
- String pin = pinInput.getText().toString().trim();
- if (TextUtils.isEmpty(pin)) {
- pin = CastConfig.DEFAULT_PIN;
- }
- boolean allowAudio = allowAudioCheck.isChecked();
+ String pin = AppPreferences.getPin(this);
+ boolean allowAudio = AppPreferences.isPlayIncomingAudio(this);
ReceiverPrefs.setAllowAudio(this, allowAudio);
Intent intent = new Intent(this, ReceiverCastService.class);
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
index 4c5c3fa..d4fd588 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -14,8 +14,11 @@ import android.os.RemoteException;
import android.util.Log;
import android.view.Surface;
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras;
@@ -166,19 +169,23 @@ public class ReceiverCastService extends Service {
}
if (intent != null && ACTION_START.equals(intent.getAction())) {
String pin = intent.getStringExtra(EXTRA_PIN);
- if (pin == null || pin.isEmpty()) {
- pin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
+ if (pin == null) {
+ pin = AppPreferences.getPin(this);
}
CastSettings fromIntent = IntentExtras.getCastSettings(intent);
if (fromIntent != null) {
settings = fromIntent;
+ } else {
+ settings = AppPreferences.loadReceiverDefaults(this);
}
allowIncomingAudio = intent.getBooleanExtra(EXTRA_ALLOW_AUDIO,
- ReceiverPrefs.isAllowAudio(this));
+ AppPreferences.isPlayIncomingAudio(this));
activePin = pin;
restartListening(pin);
} else if (intent == null && phase == Phase.IDLE) {
- allowIncomingAudio = ReceiverPrefs.isAllowAudio(this);
+ allowIncomingAudio = AppPreferences.isPlayIncomingAudio(this);
+ settings = AppPreferences.loadReceiverDefaults(this);
+ activePin = AppPreferences.getPin(this);
restartListening(activePin);
}
return START_STICKY;
@@ -203,6 +210,8 @@ public class ReceiverCastService extends Service {
acquireWakeLock();
startForeground(CastNotifications.ID_RECEIVER,
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
+ CastActiveState.setReceiverListening(true);
+ CastTrayNotifier.refresh(this);
if (videoDecoder != null) {
videoDecoder.release();
@@ -592,8 +601,8 @@ public class ReceiverCastService extends Service {
discovery = new DiscoveryManager(this);
com.foxx.androidcast.network.CastTransportFactory.init(this);
int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
- discovery.startAnnouncing(android.os.Build.MODEL, announcePort,
- settings.getTransport(), true);
+ discovery.startAnnouncing(this, AppPreferences.getUsername(this), announcePort,
+ settings.getTransport(), true, activePin);
Log.i(TAG, "Discovery announcing on port " + announcePort);
}
@@ -878,6 +887,8 @@ public class ReceiverCastService extends Service {
private void releaseAll() {
finishSessionStatsAsync();
+ CastActiveState.setReceiverListening(false);
+ CastTrayNotifier.refresh(this);
phase = Phase.IDLE;
resetStreamState();
playbackLaunched = false;
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 ab92e8b..0428efa 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
@@ -3,6 +3,7 @@ package com.foxx.androidcast.receiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
+import android.os.Build;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.Handler;
@@ -21,9 +22,11 @@ import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.ImageView;
import android.widget.TextView;
-import androidx.appcompat.app.AppCompatActivity;
-
+import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastConfig;
+import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.DrawerHostActivity;
+import com.foxx.androidcast.CastThemeHelper;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.R;
@@ -37,12 +40,13 @@ import androidx.core.text.HtmlCompat;
* Fullscreen display: dancing-robot overlay while listening / waiting;
* video surface shown only after the first decoded frame.
*/
-public class ReceiverPlaybackActivity extends AppCompatActivity {
+public class ReceiverPlaybackActivity extends DrawerHostActivity {
public static final String EXTRA_WIDTH = "video_width";
public static final String EXTRA_HEIGHT = "video_height";
public static final String EXTRA_FINISH = "finish_playback";
public static final String EXTRA_SHOW_AWAITING = "show_awaiting";
public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message";
+ public static final String EXTRA_AUTO_START_LISTENING = "auto_start_listening";
private AspectRatioFrameLayout aspectContainer;
private TextureView textureView;
@@ -132,13 +136,23 @@ public class ReceiverPlaybackActivity extends AppCompatActivity {
}
};
+ @Override
+ protected void applyActivityTheme() {
+ CastThemeHelper.applyTheme(this);
+ setTheme(R.style.Theme_AndroidCast_Fullscreen);
+ }
+
+ @Override
+ protected int getContentLayoutId() {
+ return R.layout.activity_playback;
+ }
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (handleCommandIntent(getIntent())) {
return;
}
- setContentView(R.layout.activity_playback);
applyFullscreen();
readSizeFromIntent(getIntent());
@@ -154,12 +168,32 @@ public class ReceiverPlaybackActivity extends AppCompatActivity {
textureView.setSurfaceTextureListener(surfaceListener);
applyAspectRatio();
- if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
+ if (getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false)) {
+ startListeningService();
+ showAwaitingOverlay(R.string.playback_listening);
+ } else if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) {
int msg = getIntent().getIntExtra(EXTRA_AWAITING_MESSAGE, R.string.playback_listening);
showAwaitingOverlay(msg);
}
}
+ private void startListeningService() {
+ String pin = AppPreferences.getPin(this);
+ boolean allowAudio = AppPreferences.isPlayIncomingAudio(this);
+ ReceiverPrefs.setAllowAudio(this, allowAudio);
+ CastSettings settings = AppPreferences.loadReceiverDefaults(this);
+ Intent intent = new Intent(this, ReceiverCastService.class);
+ intent.setAction(ReceiverCastService.ACTION_START);
+ intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
+ intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio);
+ intent.putExtra(CastSettings.EXTRA, settings);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent);
+ } else {
+ startService(intent);
+ }
+ }
+
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
diff --git a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
index 6217d97..21f09bd 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java
@@ -224,6 +224,8 @@ public final class MultiCastCoordinator {
s.setBitrateMode(src.getBitrateMode());
s.setNetworkAdoption(src.getNetworkAdoption());
s.setCaptureMode(src.getCaptureMode());
+ s.setStreamProtection(src.getStreamProtection());
+ s.setReceiverDisplayResolution(src.getReceiverDisplayResolution());
return s;
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
index 9dd9b56..633e3de 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -21,7 +21,9 @@ import android.util.Log;
import android.view.Surface;
import android.view.WindowManager;
+import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastActiveState;
+import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution;
@@ -63,6 +65,7 @@ public class ScreenCastService extends Service implements
public static final String EXTRA_PORT = "port";
public static final String EXTRA_PIN = "pin";
public static final String EXTRA_DEVICE_NAME = "device_name";
+ public static final String EXTRA_SENDER_NAME = "sender_name";
public static final String EXTRA_TARGETS = "cast_targets";
public static final String EXTRA_CALIBRATION = "calibration_mode";
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";
@@ -181,22 +184,28 @@ public class ScreenCastService extends Service implements
String host = intent.getStringExtra(EXTRA_HOST);
int port = intent.getIntExtra(EXTRA_PORT, com.foxx.androidcast.CastConfig.CAST_PORT);
String pin = intent.getStringExtra(EXTRA_PIN);
+ if (pin == null) {
+ pin = "";
+ }
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
+ String senderName = intent.getStringExtra(EXTRA_SENDER_NAME);
CastSettings settings = IntentExtras.getCastSettings(intent);
boolean calibration = settings != null
&& settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
- boolean camera = settings != null
- && settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
+ boolean camera = settings != null && settings.isCameraCapture();
if (!calibration && !camera && data == null) {
failAndStop(getString(R.string.error_projection_data));
return;
}
- if (host == null || pin == null || settings == null) {
+ if (host == null || settings == null) {
failAndStop(getString(R.string.error_missing_params));
return;
}
- cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
+ if (senderName == null || senderName.isEmpty()) {
+ senderName = AppPreferences.getUsername(this);
+ }
+ cameraMode = settings.isCameraCapture();
calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
failAndStop(getString(R.string.capture_camera_not_implemented));
@@ -222,8 +231,7 @@ public class ScreenCastService extends Service implements
}
}));
updateStatus(getString(R.string.connecting_to, host));
- multiCoordinator.connectAndHandshake(targets, settings,
- deviceName != null ? deviceName : "Sender", pin);
+ multiCoordinator.connectAndHandshake(targets, settings, senderName, pin);
List peers = multiCoordinator.getPeers();
if (peers.isEmpty()) {
throw new IOException("No receivers connected");
@@ -247,6 +255,7 @@ public class ScreenCastService extends Service implements
}
casting.set(true);
CastActiveState.setSenderCasting(true);
+ CastTrayNotifier.refresh(this);
activeSettings = settings;
sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
sessionStatsRecorder.mergeSettings(settings);
@@ -382,7 +391,8 @@ public class ScreenCastService extends Service implements
}
cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this);
try {
- cameraCapture.start(surface, captureWidth, captureHeight);
+ cameraCapture.start(surface, captureWidth, captureHeight, activeSettings != null
+ && activeSettings.isFrontCamera());
} catch (android.hardware.camera2.CameraAccessException e) {
throw new IOException("Camera open failed: " + e.getMessage(), e);
}
@@ -790,6 +800,7 @@ public class ScreenCastService extends Service implements
stopping.set(true);
casting.set(false);
CastActiveState.setSenderCasting(false);
+ CastTrayNotifier.refresh(this);
networkFeedback = null;
calibrationMode = false;
cameraMode = false;
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 140e49c..d76ce02 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
@@ -2,16 +2,12 @@ package com.foxx.androidcast.sender;
import android.app.Activity;
import android.content.Intent;
-import android.graphics.drawable.Animatable;
-import android.graphics.drawable.Drawable;
import android.media.projection.MediaProjectionConfig;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
-import android.provider.Settings;
-import android.text.TextUtils;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
@@ -22,33 +18,37 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
-import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AlertDialog;
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.DrawerHostActivity;
import com.foxx.androidcast.CastActiveState;
+import com.foxx.androidcast.CastActivityTracker;
import com.foxx.androidcast.CastConfig;
-import com.foxx.androidcast.sender.CastReceiverTarget;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.PlatformCastSupport;
import com.foxx.androidcast.R;
-import com.foxx.androidcast.SettingsUi;
import com.foxx.androidcast.discovery.DiscoveryManager;
+import com.google.android.material.switchmaterial.SwitchMaterial;
-public class SenderActivity extends AppCompatActivity {
+public class SenderActivity extends DrawerHostActivity {
private static final int REQUEST_MEDIA_PROJECTION = 2001;
- private static final long BACK_STOP_INTERVAL_MS = 2_000;
- private static final int DEV_TAP_UNLOCK = 7;
+ private static final int REQUEST_PREVIEW_PROJECTION = 2002;
private final CastSettings castSettings = new CastSettings();
private final Handler handler = new Handler(Looper.getMainLooper());
private DiscoveryManager discovery;
private DeviceListAdapter deviceAdapter;
+ private SenderCapturePreview capturePreview;
+ private SenderScreenPreview screenPreview;
private TextView statusText;
- private TextView titleText;
private ProgressBar discoveryProgress;
- private Button refreshButton;
- private View advancedPanel;
+ private EditText pinInput;
+ private SwitchMaterial muteSwitch;
+ private Button startButton;
+ private boolean pinLocked;
private enum DiscoveryPhase {
IDLE, SCANNING, PAUSED
@@ -57,62 +57,55 @@ public class SenderActivity extends AppCompatActivity {
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
private int discoverySecondsLeft;
private final Runnable autoScanRunnable = () -> beginScanPass(false);
- private final Runnable discoveryCountdownTick = new Runnable() {
- @Override
- public void run() {
- if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
- if (discoverySecondsLeft > 0) {
- discoverySecondsLeft--;
- }
- updateRefreshButtonLabel();
- handler.postDelayed(this, 1000);
- }
- }
- };
- private EditText pinInput;
- private EditText manualHostInput;
- private EditText manualPortInput;
- private int titleTapCount;
- private long lastTitleTapMs;
- private boolean useManualConnect;
- private long lastBackPressMs;
+ private String pendingPin;
+ private String pendingHost;
+ private int pendingPort;
+ private String pendingDeviceName;
+ private java.util.ArrayList pendingTargets;
+ private boolean pendingCameraLaunch;
+ private boolean previewConsentDeclined;
+ private boolean previewConsentInFlight;
+
+ @Override
+ protected int getContentLayoutId() {
+ return R.layout.activity_sender;
+ }
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_sender);
PermissionHelper.request(this);
- SettingsUi.bindSender(this, castSettings);
- titleText = findViewById(R.id.text_sender_title);
- pinInput = findViewById(R.id.edit_pin);
- pinInput.setText(CastConfig.DEFAULT_PIN);
+ castSettings.copyFrom(AppPreferences.loadSenderDefaults(this));
+ screenPreview = new SenderScreenPreview(this);
+
statusText = findViewById(R.id.text_status);
+ pinInput = findViewById(R.id.edit_pin);
+ muteSwitch = findViewById(R.id.switch_mute);
+ startButton = findViewById(R.id.btn_start_cast);
discoveryProgress = findViewById(R.id.progress_discovery);
- refreshButton = findViewById(R.id.btn_refresh);
- advancedPanel = findViewById(R.id.panel_advanced);
- manualHostInput = findViewById(R.id.edit_manual_host);
- manualPortInput = findViewById(R.id.edit_manual_port);
- titleText.setOnClickListener(v -> {
- long now = System.currentTimeMillis();
- if (now - lastTitleTapMs > 2000) {
- titleTapCount = 0;
+ pinInput.setText(AppPreferences.getPin(this));
+ muteSwitch.setChecked(!castSettings.isAudioRequested());
+ muteSwitch.setOnCheckedChangeListener((btn, muted) ->
+ castSettings.setAudioEnabled(!muted));
+
+ capturePreview = new SenderCapturePreview(
+ findViewById(R.id.texture_preview),
+ findViewById(R.id.text_preview_label),
+ screenPreview);
+ View.OnClickListener requestPreviewConsent = v -> {
+ if (screenPreview.needsProjection(castSettings.getCaptureMode())) {
+ previewConsentDeclined = false;
+ maybeRequestPreviewConsent();
}
- lastTitleTapMs = now;
- titleTapCount++;
- if (titleTapCount >= DEV_TAP_UNLOCK) {
- titleTapCount = 0;
- advancedPanel.setVisibility(
- advancedPanel.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE);
- Toast.makeText(this,
- advancedPanel.getVisibility() == View.VISIBLE
- ? R.string.advanced_unlocked : R.string.advanced_hidden,
- Toast.LENGTH_SHORT).show();
- }
- });
+ };
+ findViewById(R.id.text_preview_label).setOnClickListener(requestPreviewConsent);
+ findViewById(R.id.texture_preview).setOnClickListener(requestPreviewConsent);
+ refreshPreview();
+ maybeRequestPreviewConsent();
ListView listView = findViewById(R.id.list_devices);
listView.setChoiceMode(CastConfig.MULTI_RECEIVER_ENABLED
@@ -121,7 +114,6 @@ public class SenderActivity extends AppCompatActivity {
listView.setAdapter(deviceAdapter);
listView.setOnItemClickListener((parent, view, position, id) -> {
deviceAdapter.setSelectedPosition(position);
- useManualConnect = false;
java.util.List selectedList =
deviceAdapter.getSelectedDevices();
if (selectedList.isEmpty()) {
@@ -158,18 +150,14 @@ public class SenderActivity extends AppCompatActivity {
public void onScanStarted() {
discoveryPhase = DiscoveryPhase.SCANNING;
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
- updateRefreshButtonLabel();
- handler.removeCallbacks(discoveryCountdownTick);
- handler.post(discoveryCountdownTick);
- showDiscoveryProgress();
+ discoveryProgress.setVisibility(View.VISIBLE);
}
@Override
public void onScanEnded() {
- hideDiscoveryProgress();
discoveryPhase = DiscoveryPhase.PAUSED;
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000);
- updateRefreshButtonLabel();
+ discoveryProgress.setVisibility(View.GONE);
handler.removeCallbacks(autoScanRunnable);
handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS);
}
@@ -179,35 +167,73 @@ public class SenderActivity extends AppCompatActivity {
@Override
public void handleOnBackPressed() {
if (CastActiveState.isSenderCasting()) {
- long now = System.currentTimeMillis();
- if (now - lastBackPressMs < BACK_STOP_INTERVAL_MS) {
- stopCastingAndExit();
- } else {
- lastBackPressMs = now;
- Toast.makeText(SenderActivity.this,
- R.string.press_back_again_to_stop_cast, Toast.LENGTH_SHORT).show();
- }
+ Toast.makeText(SenderActivity.this, R.string.cast_in_progress_pin_locked,
+ Toast.LENGTH_SHORT).show();
} else {
finish();
}
}
});
- refreshButton.setOnClickListener(v -> onRefreshClicked());
- findViewById(R.id.btn_start_cast).setOnClickListener(v ->
+ startButton.setOnClickListener(v ->
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
- findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
- useManualConnect = true;
- PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow);
- });
}
@Override
protected void onResume() {
super.onResume();
+ castSettings.copyFrom(AppPreferences.loadSenderDefaults(this));
+ muteSwitch.setChecked(!castSettings.isAudioRequested());
+ pinLocked = CastActiveState.isSenderCasting();
+ pinInput.setEnabled(!pinLocked);
+ startButton.setEnabled(!pinLocked);
+ muteSwitch.setEnabled(!pinLocked);
+ if (pinLocked) {
+ Toast.makeText(this, R.string.cast_in_progress_pin_locked, Toast.LENGTH_SHORT).show();
+ }
+ refreshPreview();
PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
}
+ private void refreshPreview() {
+ if (capturePreview == null) {
+ return;
+ }
+ capturePreview.updateForMode(castSettings.getCaptureMode());
+ }
+
+ private void maybeRequestPreviewConsent() {
+ if (previewConsentDeclined || previewConsentInFlight) {
+ return;
+ }
+ CastSettings.CaptureMode mode = castSettings.getCaptureMode();
+ if (!screenPreview.needsProjection(mode) || screenPreview.hasConsent()) {
+ return;
+ }
+ previewConsentInFlight = true;
+ MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
+ startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_PREVIEW_PROJECTION);
+ }
+
+ @Override
+ protected void onPause() {
+ if (capturePreview != null) {
+ capturePreview.pause();
+ }
+ super.onPause();
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (capturePreview != null) {
+ capturePreview.stop();
+ }
+ if (screenPreview != null) {
+ screenPreview.releaseConsent();
+ }
+ super.onDestroy();
+ }
+
@Override
protected void onStart() {
super.onStart();
@@ -216,25 +242,14 @@ public class SenderActivity extends AppCompatActivity {
@Override
protected void onStop() {
- handler.removeCallbacks(discoveryCountdownTick);
handler.removeCallbacks(autoScanRunnable);
if (discovery != null) {
discovery.stop();
}
discoveryPhase = DiscoveryPhase.IDLE;
- updateRefreshButtonLabel();
- hideDiscoveryProgress();
super.onStop();
}
- private void onRefreshClicked() {
- if (discoveryPhase == DiscoveryPhase.SCANNING) {
- return;
- }
- handler.removeCallbacks(autoScanRunnable);
- beginScanPass(true);
- }
-
private void beginScanPass(boolean userInitiated) {
if (discovery != null && discovery.isBrowsing()) {
return;
@@ -254,42 +269,6 @@ public class SenderActivity extends AppCompatActivity {
discovery.startBrowsing();
}
- private void updateRefreshButtonLabel() {
- if (refreshButton == null) {
- return;
- }
- if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) {
- refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft)));
- } else {
- refreshButton.setText(R.string.refresh);
- }
- }
-
- private void showDiscoveryProgress() {
- discoveryProgress.setVisibility(View.VISIBLE);
- discoveryProgress.bringToFront();
- Drawable indeterminate = discoveryProgress.getIndeterminateDrawable();
- if (indeterminate instanceof Animatable) {
- ((Animatable) indeterminate).start();
- }
- }
-
- private void hideDiscoveryProgress() {
- discoveryProgress.setVisibility(View.GONE);
- Drawable indeterminate = discoveryProgress.getIndeterminateDrawable();
- if (indeterminate instanceof Animatable) {
- ((Animatable) indeterminate).stop();
- }
- }
-
- private void stopCastingAndExit() {
- Intent stop = new Intent(this, ScreenCastService.class);
- stop.setAction(ScreenCastService.ACTION_STOP);
- startService(stop);
- CastActiveState.setSenderCasting(false);
- finish();
- }
-
private void startCastFlow() {
PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
if (!PermissionHelper.hasAll(this)) {
@@ -299,62 +278,92 @@ public class SenderActivity extends AppCompatActivity {
Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show();
return;
}
+
String pin = pinInput.getText().toString().trim();
- if (TextUtils.isEmpty(pin)) {
- Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show();
+ if (!AppPreferences.getPin(this).equals(pin)) {
+ AppPreferences.setPin(this, pin);
+ }
+
+ java.util.List selectedList =
+ deviceAdapter.getSelectedDevices();
+ if (selectedList.isEmpty()) {
+ Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ for (DiscoveryManager.DiscoveredDevice d : selectedList) {
+ if (!d.pinMatches(pin)) {
+ showPinMismatchDialog(selectedList);
+ return;
+ }
+ }
+
+ if (CastConfig.MULTI_RECEIVER_ENABLED && selectedList.size() > CastConfig.MAX_RECEIVERS) {
+ Toast.makeText(this, getString(R.string.too_many_receivers, CastConfig.MAX_RECEIVERS),
+ Toast.LENGTH_LONG).show();
return;
}
- if (useManualConnect) {
- String host = manualHostInput.getText().toString().trim();
- String portStr = manualPortInput.getText().toString().trim();
- if (TextUtils.isEmpty(host) || TextUtils.isEmpty(portStr)) {
- Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show();
+ pendingTargets = new java.util.ArrayList<>();
+ for (DiscoveryManager.DiscoveredDevice d : selectedList) {
+ if (!CastConfig.MULTI_RECEIVER_ENABLED
+ && !castSettings.getTransport().equals(d.transport)) {
+ Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
return;
}
- pendingHost = host;
- try {
- pendingPort = Integer.parseInt(portStr);
- } catch (NumberFormatException e) {
- Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show();
- return;
- }
- pendingDeviceName = host;
- } else {
- java.util.List selectedList =
- deviceAdapter.getSelectedDevices();
- if (selectedList.isEmpty()) {
- Toast.makeText(this, R.string.select_receiver, Toast.LENGTH_SHORT).show();
- return;
- }
- if (CastConfig.MULTI_RECEIVER_ENABLED && selectedList.size() > CastConfig.MAX_RECEIVERS) {
- Toast.makeText(this, getString(R.string.too_many_receivers, CastConfig.MAX_RECEIVERS),
- Toast.LENGTH_LONG).show();
- return;
- }
- pendingTargets = new java.util.ArrayList<>();
- for (DiscoveryManager.DiscoveredDevice d : selectedList) {
- if (!CastConfig.MULTI_RECEIVER_ENABLED
- && !castSettings.getTransport().equals(d.transport)) {
- Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
- return;
- }
- pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
- d.videoCodecs, d.audioCodecs, d.placeholderId));
- }
- DiscoveryManager.DiscoveredDevice first = selectedList.get(0);
- pendingHost = first.host;
- pendingPort = first.port;
- pendingDeviceName = selectedList.size() > 1
- ? first.name + " +" + (selectedList.size() - 1) : first.name;
+ pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
+ d.videoCodecs, d.audioCodecs, d.placeholderId));
}
-
+ DiscoveryManager.DiscoveredDevice first = selectedList.get(0);
+ pendingHost = first.host;
+ pendingPort = first.port;
+ pendingDeviceName = selectedList.size() > 1
+ ? first.name + " +" + (selectedList.size() - 1) : first.name;
pendingPin = pin;
+
+ proceedCaptureConsent();
+ }
+
+ private void showPinMismatchDialog(java.util.List selectedList) {
+ final EditText input = new EditText(this);
+ input.setHint(R.string.pin_session_hint);
+ input.setInputType(android.text.InputType.TYPE_CLASS_NUMBER
+ | android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD);
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.pin_mismatch_title)
+ .setMessage(R.string.pin_mismatch_message)
+ .setView(input)
+ .setPositiveButton(android.R.string.ok, (d, w) -> {
+ String sessionPin = input.getText().toString().trim();
+ pinInput.setText(sessionPin);
+ for (DiscoveryManager.DiscoveredDevice dev : selectedList) {
+ if (!dev.pinMatches(sessionPin)) {
+ Toast.makeText(this, R.string.pin_mismatch_title, Toast.LENGTH_LONG).show();
+ return;
+ }
+ }
+ pendingPin = sessionPin;
+ pendingTargets = new java.util.ArrayList<>();
+ for (DiscoveryManager.DiscoveredDevice dev : selectedList) {
+ pendingTargets.add(new CastReceiverTarget(dev.name, dev.host, dev.port,
+ dev.transport, dev.videoCodecs, dev.audioCodecs, dev.placeholderId));
+ }
+ DiscoveryManager.DiscoveredDevice first = selectedList.get(0);
+ pendingHost = first.host;
+ pendingPort = first.port;
+ pendingDeviceName = selectedList.size() > 1
+ ? first.name + " +" + (selectedList.size() - 1) : first.name;
+ proceedCaptureConsent();
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void proceedCaptureConsent() {
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
launchCastService(null, Activity.RESULT_OK);
return;
}
- if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) {
+ if (castSettings.isCameraCapture()) {
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
androidx.core.app.ActivityCompat.requestPermissions(this,
@@ -365,6 +374,11 @@ public class SenderActivity extends AppCompatActivity {
launchCastService(null, Activity.RESULT_OK);
return;
}
+ if (screenPreview.hasConsent()) {
+ screenPreview.stop();
+ launchCastService(screenPreview.getProjectionData(), screenPreview.getProjectionResultCode());
+ return;
+ }
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
}
@@ -378,14 +392,6 @@ public class SenderActivity extends AppCompatActivity {
return mgr.createScreenCaptureIntent();
}
- private boolean pendingCameraLaunch;
-
- private String pendingPin;
- private String pendingHost;
- private int pendingPort;
- private String pendingDeviceName;
- private java.util.ArrayList pendingTargets;
-
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
@@ -402,6 +408,17 @@ public class SenderActivity extends AppCompatActivity {
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == REQUEST_PREVIEW_PROJECTION) {
+ previewConsentInFlight = false;
+ if (resultCode == Activity.RESULT_OK && data != null) {
+ screenPreview.setProjectionResult(resultCode, data);
+ previewConsentDeclined = false;
+ } else {
+ previewConsentDeclined = true;
+ }
+ refreshPreview();
+ return;
+ }
if (requestCode != REQUEST_MEDIA_PROJECTION) {
return;
}
@@ -409,14 +426,19 @@ public class SenderActivity extends AppCompatActivity {
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
return;
}
+ screenPreview.setProjectionResult(resultCode, data);
launchCastService(data, resultCode);
}
private void launchCastService(Intent projectionData, int resultCode) {
- String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME);
- if (deviceName == null) {
- deviceName = Build.MODEL;
+ if (screenPreview != null) {
+ screenPreview.stop();
}
+ pinLocked = true;
+ pinInput.setEnabled(false);
+ startButton.setEnabled(false);
+ muteSwitch.setEnabled(false);
+
Intent serviceIntent = new Intent(this, ScreenCastService.class);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
if (projectionData != null) {
@@ -424,8 +446,9 @@ public class SenderActivity extends AppCompatActivity {
}
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
- serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin);
- serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName);
+ serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin != null ? pendingPin : "");
+ serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName);
+ serviceIntent.putExtra(ScreenCastService.EXTRA_SENDER_NAME, AppPreferences.getUsername(this));
serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true);
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java
new file mode 100644
index 0000000..976fd9d
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java
@@ -0,0 +1,274 @@
+package com.foxx.androidcast.sender;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.graphics.Canvas;
+import android.graphics.SurfaceTexture;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraManager;
+import android.hardware.camera2.CaptureRequest;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+import android.util.Size;
+import android.view.TextureView;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+
+import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.R;
+import com.foxx.androidcast.sender.calibration.TvCalibrationGenerator;
+
+import java.util.Collections;
+
+/** Live preview of the configured capture source on the sender screen (not the cast pipeline). */
+public final class SenderCapturePreview {
+ private static final String TAG = "SenderCapturePreview";
+
+ private final TextureView textureView;
+ private final TextView labelView;
+ private final SenderScreenPreview screenPreview;
+ private HandlerThread thread;
+ private Handler handler;
+ private CameraDevice camera;
+ private android.hardware.camera2.CameraCaptureSession session;
+ private boolean useFront;
+
+ public SenderCapturePreview(TextureView texture, TextView label, SenderScreenPreview screen) {
+ this.textureView = texture;
+ this.labelView = label;
+ this.screenPreview = screen;
+ }
+
+ public SenderScreenPreview getScreenPreview() {
+ return screenPreview;
+ }
+
+ public void updateForMode(CastSettings.CaptureMode mode) {
+ stopCamera();
+ if (mode == CastSettings.CaptureMode.CAMERA) {
+ useFront = false;
+ startCameraPreview();
+ } else if (mode == CastSettings.CaptureMode.CAMERA_FRONT) {
+ useFront = true;
+ startCameraPreview();
+ } else if (mode == CastSettings.CaptureMode.CALIBRATION_TEST) {
+ showCalibrationPreview();
+ } else if (screenPreview != null && screenPreview.needsProjection(mode)) {
+ if (screenPreview.hasProjection()) {
+ labelView.setVisibility(android.view.View.GONE);
+ textureView.setVisibility(android.view.View.VISIBLE);
+ screenPreview.attach(textureView, mode);
+ } else {
+ showLabel(R.string.preview_tap_for_screen);
+ }
+ } else {
+ showLabel(R.string.preview_placeholder);
+ }
+ }
+
+ private void showLabel(int textRes) {
+ if (screenPreview != null) {
+ screenPreview.stop();
+ }
+ textureView.setVisibility(android.view.View.GONE);
+ labelView.setVisibility(android.view.View.VISIBLE);
+ labelView.setText(labelView.getContext().getString(textRes));
+ }
+
+ private void showCalibrationPreview() {
+ if (screenPreview != null) {
+ screenPreview.stop();
+ }
+ labelView.setVisibility(android.view.View.GONE);
+ textureView.setVisibility(android.view.View.VISIBLE);
+ textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
+ @Override
+ public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
+ drawCalibrationFrame();
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
+ drawCalibrationFrame();
+ }
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(SurfaceTexture surface) {}
+ });
+ if (textureView.isAvailable()) {
+ drawCalibrationFrame();
+ }
+ }
+
+ private void drawCalibrationFrame() {
+ int w = Math.max(320, textureView.getWidth());
+ int h = Math.max(240, textureView.getHeight());
+ Canvas canvas = textureView.lockCanvas();
+ if (canvas == null) {
+ return;
+ }
+ try {
+ TvCalibrationGenerator.drawChart(canvas, w, h, textureView.getContext());
+ } finally {
+ textureView.unlockCanvasAndPost(canvas);
+ }
+ }
+
+ private void startCameraPreview() {
+ if (screenPreview != null) {
+ screenPreview.stop();
+ }
+ Context ctx = textureView.getContext();
+ if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA)
+ != PackageManager.PERMISSION_GRANTED) {
+ showLabel(R.string.capture_camera_not_implemented);
+ return;
+ }
+ labelView.setVisibility(android.view.View.GONE);
+ textureView.setVisibility(android.view.View.VISIBLE);
+ if (textureView.isAvailable()) {
+ openCamera(textureView.getSurfaceTexture());
+ } else {
+ textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
+ @Override
+ public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
+ openCamera(surface);
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {}
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
+ stop();
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(SurfaceTexture surface) {}
+ });
+ }
+ }
+
+ private void openCamera(SurfaceTexture surface) {
+ Context ctx = textureView.getContext().getApplicationContext();
+ thread = new HandlerThread("SenderPreviewCam");
+ thread.start();
+ handler = new Handler(thread.getLooper());
+ try {
+ CameraManager mgr = ctx.getSystemService(CameraManager.class);
+ String id = chooseCamera(mgr, useFront);
+ if (id == null) {
+ showLabel(R.string.capture_camera_not_implemented);
+ return;
+ }
+ surface.setDefaultBufferSize(640, 480);
+ mgr.openCamera(id, new CameraDevice.StateCallback() {
+ @Override
+ public void onOpened(CameraDevice device) {
+ camera = device;
+ try {
+ android.view.Surface previewSurface = new android.view.Surface(surface);
+ CaptureRequest.Builder b =
+ device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
+ b.addTarget(previewSurface);
+ device.createCaptureSession(Collections.singletonList(previewSurface),
+ new android.hardware.camera2.CameraCaptureSession.StateCallback() {
+ @Override
+ public void onConfigured(
+ android.hardware.camera2.CameraCaptureSession s) {
+ session = s;
+ try {
+ s.setRepeatingRequest(b.build(), null, handler);
+ } catch (CameraAccessException e) {
+ Log.w(TAG, "Preview request failed", e);
+ }
+ }
+
+ @Override
+ public void onConfigureFailed(
+ android.hardware.camera2.CameraCaptureSession s) {
+ Log.w(TAG, "Preview session failed");
+ }
+ }, handler);
+ } catch (CameraAccessException e) {
+ Log.w(TAG, "Preview session", e);
+ }
+ }
+
+ @Override
+ public void onDisconnected(CameraDevice device) {
+ device.close();
+ }
+
+ @Override
+ public void onError(CameraDevice device, int error) {
+ device.close();
+ }
+ }, handler);
+ } catch (CameraAccessException | SecurityException e) {
+ Log.w(TAG, "Open camera failed", e);
+ showLabel(R.string.capture_camera_not_implemented);
+ }
+ }
+
+ private static String chooseCamera(CameraManager mgr, boolean front) throws CameraAccessException {
+ int want = front ? CameraCharacteristics.LENS_FACING_FRONT
+ : CameraCharacteristics.LENS_FACING_BACK;
+ for (String id : mgr.getCameraIdList()) {
+ Integer facing = mgr.getCameraCharacteristics(id)
+ .get(CameraCharacteristics.LENS_FACING);
+ if (facing != null && facing == want) {
+ return id;
+ }
+ }
+ String[] ids = mgr.getCameraIdList();
+ return ids.length > 0 ? ids[0] : null;
+ }
+
+ public void pause() {
+ stopCamera();
+ if (screenPreview != null) {
+ screenPreview.pausePreview();
+ }
+ }
+
+ public void stop() {
+ stopCamera();
+ if (screenPreview != null) {
+ screenPreview.stop();
+ }
+ }
+
+ private void stopCamera() {
+ if (session != null) {
+ try {
+ session.close();
+ } catch (Exception ignored) {
+ }
+ session = null;
+ }
+ if (camera != null) {
+ try {
+ camera.close();
+ } catch (Exception ignored) {
+ }
+ camera = null;
+ }
+ if (thread != null) {
+ thread.quitSafely();
+ thread = null;
+ }
+ handler = null;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java
new file mode 100644
index 0000000..87d7619
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java
@@ -0,0 +1,150 @@
+package com.foxx.androidcast.sender;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.SurfaceTexture;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.VirtualDisplay;
+import android.media.projection.MediaProjection;
+import android.media.projection.MediaProjectionManager;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Surface;
+import android.view.TextureView;
+import android.view.WindowManager;
+
+import com.foxx.androidcast.CastSettings;
+
+/**
+ * Live screen preview via MediaProjection into a {@link TextureView} (prep only, not the cast encoder).
+ */
+public final class SenderScreenPreview {
+ private static final String TAG = "SenderScreenPreview";
+ private static final int PREVIEW_DPI = 160;
+
+ private final Context appContext;
+ private MediaProjection projection;
+ private VirtualDisplay virtualDisplay;
+ private int resultCode;
+ private Intent resultData;
+
+ public SenderScreenPreview(Context context) {
+ appContext = context.getApplicationContext();
+ }
+
+ public boolean needsProjection(CastSettings.CaptureMode mode) {
+ return mode == CastSettings.CaptureMode.FULL_SCREEN
+ || mode == CastSettings.CaptureMode.USER_CHOICE;
+ }
+
+ public void setProjectionResult(int resultCode, Intent data) {
+ this.resultCode = resultCode;
+ this.resultData = data != null ? new Intent(data) : null;
+ }
+
+ public Intent getProjectionData() {
+ return resultData != null ? new Intent(resultData) : null;
+ }
+
+ public int getProjectionResultCode() {
+ return resultCode;
+ }
+
+ /** User granted capture consent (may not have an active {@link MediaProjection} after pause). */
+ public boolean hasConsent() {
+ return resultCode == Activity.RESULT_OK && resultData != null;
+ }
+
+ public boolean hasProjection() {
+ return hasConsent();
+ }
+
+ public void attach(TextureView textureView, CastSettings.CaptureMode mode) {
+ stop();
+ if (!needsProjection(mode) || resultData == null) {
+ return;
+ }
+ Runnable start = () -> startOnTexture(textureView, mode);
+ if (textureView.isAvailable()) {
+ start.run();
+ } else {
+ textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
+ @Override
+ public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
+ startOnTexture(textureView, mode);
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {}
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
+ stop();
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(SurfaceTexture surface) {}
+ });
+ }
+ }
+
+ private void startOnTexture(TextureView textureView, CastSettings.CaptureMode mode) {
+ try {
+ MediaProjectionManager mgr =
+ appContext.getSystemService(MediaProjectionManager.class);
+ projection = mgr.getMediaProjection(resultCode, resultData);
+ if (projection == null) {
+ return;
+ }
+ DisplayMetrics metrics = new DisplayMetrics();
+ WindowManager wm = (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE);
+ wm.getDefaultDisplay().getRealMetrics(metrics);
+ int w = Math.max(320, textureView.getWidth() > 0 ? textureView.getWidth() : metrics.widthPixels / 3);
+ int h = Math.max(240, textureView.getHeight() > 0 ? textureView.getHeight() : metrics.heightPixels / 3);
+ SurfaceTexture st = textureView.getSurfaceTexture();
+ if (st == null) {
+ return;
+ }
+ st.setDefaultBufferSize(w, h);
+ Surface surface = new Surface(st);
+ virtualDisplay = projection.createVirtualDisplay(
+ "CastPreview",
+ w,
+ h,
+ PREVIEW_DPI,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ surface,
+ null,
+ null);
+ Log.i(TAG, "Screen preview " + w + "x" + h);
+ } catch (Exception e) {
+ Log.w(TAG, "Screen preview failed: " + e.getMessage());
+ stop();
+ }
+ }
+
+ /** Tear down preview display only; keep consent for resume or cast START. */
+ public void pausePreview() {
+ if (virtualDisplay != null) {
+ virtualDisplay.release();
+ virtualDisplay = null;
+ }
+ }
+
+ public void stop() {
+ pausePreview();
+ if (projection != null) {
+ projection.stop();
+ projection = null;
+ }
+ }
+
+ /** Release consent and active capture (leaving sender screen). */
+ public void releaseConsent() {
+ stop();
+ resultCode = 0;
+ resultData = null;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java
index 434b4b7..4e8976c 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java
@@ -32,16 +32,17 @@ public final class CameraCastCapture {
appContext = context.getApplicationContext();
}
- public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException {
+ public void start(Surface encoderSurface, int targetWidth, int targetHeight, boolean useFrontCamera)
+ throws CameraAccessException {
stop();
thread = new HandlerThread("CameraCast");
thread.start();
handler = new Handler(thread.getLooper());
CameraManager mgr = appContext.getSystemService(CameraManager.class);
- cameraId = chooseCameraId(mgr);
+ cameraId = chooseCameraId(mgr, useFrontCamera);
if (cameraId == null) {
throw new CameraAccessException(CameraAccessException.CAMERA_ERROR,
- "No back camera available");
+ useFrontCamera ? "No front camera available" : "No back camera available");
}
Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight);
Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight());
@@ -97,11 +98,14 @@ public final class CameraCastCapture {
}, handler);
}
- private static String chooseCameraId(CameraManager mgr) throws CameraAccessException {
+ private static String chooseCameraId(CameraManager mgr, boolean useFrontCamera) throws CameraAccessException {
+ int want = useFrontCamera
+ ? CameraCharacteristics.LENS_FACING_FRONT
+ : CameraCharacteristics.LENS_FACING_BACK;
for (String id : mgr.getCameraIdList()) {
CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
- if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
+ if (facing != null && facing == want) {
return id;
}
}
diff --git a/app/src/main/res/drawable/bg_settings_section_header.xml b/app/src/main/res/drawable/bg_settings_section_header.xml
new file mode 100644
index 0000000..f7ad3d5
--- /dev/null
+++ b/app/src/main/res/drawable/bg_settings_section_header.xml
@@ -0,0 +1,24 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/activity_sender.xml b/app/src/main/res/layout-land/activity_sender.xml
new file mode 100644
index 0000000..6fd74bd
--- /dev/null
+++ b/app/src/main/res/layout-land/activity_sender.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_cast_settings.xml b/app/src/main/res/layout/activity_cast_settings.xml
new file mode 100644
index 0000000..8c669b5
--- /dev/null
+++ b/app/src/main/res/layout/activity_cast_settings.xml
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_drawer_host.xml b/app/src/main/res/layout/activity_drawer_host.xml
new file mode 100644
index 0000000..9040103
--- /dev/null
+++ b/app/src/main/res/layout/activity_drawer_host.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index a0793cd..bfbc3ff 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -25,6 +25,7 @@
android:id="@+id/btn_receive"
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
android:text="@string/receive_screen" />
+
+
diff --git a/app/src/main/res/layout/activity_receiver.xml b/app/src/main/res/layout/activity_receiver.xml
index ad34a3d..bcca5d3 100644
--- a/app/src/main/res/layout/activity_receiver.xml
+++ b/app/src/main/res/layout/activity_receiver.xml
@@ -1,49 +1,39 @@
-
+ android:gravity="center"
+ android:orientation="vertical"
+ android:padding="24dp">
-
+ android:gravity="center"
+ android:text="@string/receiver_title"
+ android:textSize="20sp"
+ android:textStyle="bold" />
-
+
-
+
-
-
-
-
-
-
-
-
-
+
+
diff --git a/app/src/main/res/layout/activity_sender.xml b/app/src/main/res/layout/activity_sender.xml
index a1e4b77..b95cefa 100644
--- a/app/src/main/res/layout/activity_sender.xml
+++ b/app/src/main/res/layout/activity_sender.xml
@@ -1,126 +1,100 @@
-
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:padding="12dp">
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_height="0dp"
+ android:layout_marginTop="8dp"
+ android:layout_weight="1"
+ android:orientation="vertical">
+ android:layout_height="0dp"
+ android:layout_weight="0.4"
+ android:choiceMode="singleChoice" />
-
+ android:layout_height="0dp"
+ android:layout_marginTop="8dp"
+ android:layout_weight="0.6"
+ android:background="#101010">
-
+
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_cast_settings.xml b/app/src/main/res/layout/fragment_cast_settings.xml
new file mode 100644
index 0000000..7b71fdc
--- /dev/null
+++ b/app/src/main/res/layout/fragment_cast_settings.xml
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_settings_section_header.xml b/app/src/main/res/layout/include_settings_section_header.xml
new file mode 100644
index 0000000..5022eb1
--- /dev/null
+++ b/app/src/main/res/layout/include_settings_section_header.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index fef2422..a7b0330 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -1,4 +1,8 @@
- #661565C0
+
+ #FF1565C0
+ #FF000000
+ #FFFFFFFF
+ #99000000
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 1fa3e93..2caa483 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,5 +1,12 @@
- #0D47A1
- #4D1565C0
+ #1565C0
+ #331565C0
+
+ #FF1565C0
+ #FF212121
+ #FFFFFFFF
+ #66000000
+ #FFF5F5F5
+ #FF212121
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
new file mode 100644
index 0000000..b615ca5
--- /dev/null
+++ b/app/src/main/res/values/ids.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2ab8e2c..28c23e6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -121,4 +121,50 @@
Video only (audio skipped)
Rotated — now %1$dx%2$d
Waiting for receiver at %1$s:%2$d…
+ Settings
+ Android Cast settings
+ Global app settings
+ Sender settings
+ Receiver settings
+ Username
+ Show tray icon while cast service is running
+ Leave empty for anonymous cast
+ Resolution
+ AUTO (fits screen)
+ Adaptive (experimental)
+ Stream protection (UDP)
+ Hint only — negotiated with receiver. UDP transports only.
+ FEC 3/4
+ FEC (Reed-Solomon)
+ NACK
+ FEC + NACK
+ Estimated (auto)
+ Estimated
+ ON
+ OFF
+ Priority hint — codec is negotiated during handshake.
+ Camera (rear)
+ Camera (front)
+ Mute
+ Source preview\n(screen capture starts after START)
+ Camera preview
+ Calibration test pattern
+ Stream options are in Android Cast settings.
+ PIN mismatch
+ The receiver expects a different PIN. Enter the PIN for this cast session.
+ Session PIN
+ Settings saved
+ PIN cannot be changed while casting
+ Cast active — tap to open app
+ Android Cast — tap to open app
+ Cast tray
+ Persistent cast status icon in the notification bar
+ Swipe from the left edge for settings
+ Theme
+ System (default)
+ Dark
+ Light
+ Inverted
+ Theme updated
+ Screen preview — grant capture when prompted
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 7e2fcfc..3e96db6 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -17,4 +17,26 @@
- true
- @android:color/transparent
+
+
+
+
+
+