1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 08:37:37 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-17 18:17:14 +02:00
parent 6b4cd9fc02
commit ab5c28b49a
41 changed files with 2852 additions and 421 deletions

View File

@@ -16,6 +16,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application <application
android:name=".AndroidCastApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_launcher_foreground" android:icon="@drawable/ic_launcher_foreground"
android:label="@string/app_name" android:label="@string/app_name"
@@ -42,6 +43,13 @@
android:exported="false" android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" /> android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
<activity
android:name=".CastSettingsActivity"
android:exported="false"
android:label="@string/cast_settings_title"
android:parentActivityName=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
<activity <activity
android:name=".receiver.ReceiverPlaybackActivity" android:name=".receiver.ReceiverPlaybackActivity"
android:exported="false" android:exported="false"

View File

@@ -0,0 +1,12 @@
package com.foxx.androidcast;
import android.app.Application;
/** Applies saved theme mode at process start. */
public class AndroidCastApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
CastThemeHelper.applyNightMode(this);
}
}

View File

@@ -0,0 +1,179 @@
package com.foxx.androidcast;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.provider.Settings;
import android.text.TextUtils;
/**
* Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options.
*/
public final class AppPreferences {
private static final String PREFS = "androidcast_app";
private static final String KEY_USERNAME = "username";
private static final String KEY_PIN = "pin";
private static final String KEY_SHOW_TRAY = "show_tray_icon";
private static final String KEY_THEME = "theme_mode";
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
private static final String PREFIX_SENDER = "sender_";
private static final String PREFIX_RECEIVER = "receiver_";
private AppPreferences() {}
public static String getUsername(Context context) {
String stored = prefs(context).getString(KEY_USERNAME, null);
if (!TextUtils.isEmpty(stored)) {
return stored.trim();
}
return defaultDeviceName(context);
}
public static void setUsername(Context context, String username) {
String v = username != null ? username.trim() : "";
if (v.isEmpty()) {
v = defaultDeviceName(context);
}
prefs(context).edit().putString(KEY_USERNAME, v).apply();
}
public static String getPin(Context context) {
return prefs(context).getString(KEY_PIN, CastConfig.DEFAULT_PIN);
}
public static void setPin(Context context, String pin) {
prefs(context).edit().putString(KEY_PIN, pin != null ? pin : "").apply();
}
public static boolean isShowTrayIcon(Context context) {
return prefs(context).getBoolean(KEY_SHOW_TRAY, true);
}
public static void setShowTrayIcon(Context context, boolean show) {
prefs(context).edit().putBoolean(KEY_SHOW_TRAY, show).apply();
}
public static CastThemeHelper.AppThemeMode getThemeMode(Context context) {
String name = prefs(context).getString(KEY_THEME, CastThemeHelper.AppThemeMode.SYSTEM.name());
try {
return CastThemeHelper.AppThemeMode.valueOf(name);
} catch (Exception e) {
return CastThemeHelper.AppThemeMode.SYSTEM;
}
}
public static void setThemeMode(Context context, CastThemeHelper.AppThemeMode mode) {
prefs(context).edit().putString(KEY_THEME,
mode != null ? mode.name() : CastThemeHelper.AppThemeMode.SYSTEM.name()).apply();
}
public static boolean isPlayIncomingAudio(Context context) {
return prefs(context).getBoolean(KEY_PLAY_INCOMING_AUDIO, true);
}
public static void setPlayIncomingAudio(Context context, boolean play) {
prefs(context).edit().putBoolean(KEY_PLAY_INCOMING_AUDIO, play).apply();
}
public static CastSettings loadSenderDefaults(Context context) {
CastSettings s = new CastSettings();
applyStoredCastSettings(context, PREFIX_SENDER, s);
s.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
s.setQuality(CastSettings.Quality.MEDIUM);
if (s.getBitrateMode() == CastSettings.BitrateMode.AUTO) {
s.setBitrateMode(CastSettings.BitrateMode.ESTIMATED);
}
if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) {
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
}
return s;
}
public static void saveSenderDefaults(Context context, CastSettings settings) {
if (settings == null) {
return;
}
storeCastSettings(context, PREFIX_SENDER, settings);
}
public static CastSettings loadReceiverDefaults(Context context) {
CastSettings s = new CastSettings();
applyStoredCastSettings(context, PREFIX_RECEIVER, s);
s.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
s.setQuality(CastSettings.Quality.MEDIUM);
if (s.getBitrateMode() == CastSettings.BitrateMode.AUTO) {
s.setBitrateMode(CastSettings.BitrateMode.ESTIMATED);
}
if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) {
s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED);
}
return s;
}
public static void saveReceiverDefaults(Context context, CastSettings settings) {
if (settings == null) {
return;
}
storeCastSettings(context, PREFIX_RECEIVER, settings);
}
public static String defaultDeviceName(Context context) {
String name = Settings.Global.getString(context.getContentResolver(), Settings.Global.DEVICE_NAME);
if (TextUtils.isEmpty(name)) {
name = Build.MODEL;
}
return name != null ? name : "Android device";
}
private static void applyStoredCastSettings(Context context, String prefix, CastSettings s) {
SharedPreferences p = prefs(context);
String transport = p.getString(prefix + "transport", CastConfig.DEFAULT_TRANSPORT);
s.setTransport(transport);
s.setResolution(readEnum(p, prefix + "resolution", CastSettings.Resolution.values(),
CastSettings.Resolution.ADAPTIVE));
s.setVideoCodec(readEnum(p, prefix + "video_codec", CastSettings.VideoCodec.values(),
CastSettings.VideoCodec.AUTO));
s.setBitrateMode(readEnum(p, prefix + "bitrate_mode", CastSettings.BitrateMode.values(),
CastSettings.BitrateMode.ESTIMATED));
s.setNetworkAdoption(readEnum(p, prefix + "network_adoption", CastSettings.NetworkAdoption.values(),
CastSettings.NetworkAdoption.ESTIMATED));
s.setCaptureMode(readEnum(p, prefix + "capture_mode", CastSettings.CaptureMode.values(),
PlatformCastSupport.defaultCaptureMode()));
s.setStreamProtection(readEnum(p, prefix + "stream_protection", CastSettings.StreamProtection.values(),
CastSettings.StreamProtection.AUTO));
s.setReceiverDisplayResolution(readEnum(p, prefix + "receiver_display",
CastSettings.ReceiverDisplayResolution.values(),
CastSettings.ReceiverDisplayResolution.AUTO_FIT));
s.setAudioEnabled(p.getBoolean(prefix + "audio_enabled", PlatformCastSupport.isAudioCaptureSupported()));
}
private static void storeCastSettings(Context context, String prefix, CastSettings s) {
prefs(context).edit()
.putString(prefix + "transport", s.getTransport())
.putString(prefix + "resolution", s.getResolution().name())
.putString(prefix + "video_codec", s.getVideoCodec().name())
.putString(prefix + "bitrate_mode", s.getBitrateMode().name())
.putString(prefix + "network_adoption", s.getNetworkAdoption().name())
.putString(prefix + "capture_mode", s.getCaptureMode().name())
.putString(prefix + "stream_protection", s.getStreamProtection().name())
.putString(prefix + "receiver_display", s.getReceiverDisplayResolution().name())
.putBoolean(prefix + "audio_enabled", s.isAudioRequested())
.apply();
}
private static <T extends Enum<T>> 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);
}
}

View File

@@ -5,6 +5,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
/** Process-wide cast session flags for UI back-key handling. */ /** Process-wide cast session flags for UI back-key handling. */
public final class CastActiveState { public final class CastActiveState {
private static final AtomicBoolean senderCasting = new AtomicBoolean(false); private static final AtomicBoolean senderCasting = new AtomicBoolean(false);
private static final AtomicBoolean receiverListening = new AtomicBoolean(false);
private CastActiveState() {} private CastActiveState() {}
@@ -15,4 +16,12 @@ public final class CastActiveState {
public static boolean isSenderCasting() { public static boolean isSenderCasting() {
return senderCasting.get(); return senderCasting.get();
} }
public static void setReceiverListening(boolean active) {
receiverListening.set(active);
}
public static boolean isReceiverListening() {
return receiverListening.get();
}
} }

View File

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

View File

@@ -42,9 +42,9 @@ public final class CastNotifications {
private static Notification build( private static Notification build(
Service service, String channel, int id, String text, Service service, String channel, int id, String text,
Class<?> serviceClass, String stopAction) { Class<?> serviceClass, String stopAction) {
Intent open = new Intent(service, MainActivity.class);
PendingIntent pi = PendingIntent.getActivity( 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); Intent stop = new Intent(service, serviceClass);
stop.setAction(stopAction); stop.setAction(stopAction);
PendingIntent stopPi = PendingIntent.getService( PendingIntent stopPi = PendingIntent.getService(

View File

@@ -5,7 +5,7 @@ import java.io.Serializable;
/** User-selected cast parameters (passed sender → service → receiver via protocol). */ /** User-selected cast parameters (passed sender → service → receiver via protocol). */
public class CastSettings implements Serializable { public class CastSettings implements Serializable {
public static final String EXTRA = "cast_settings"; public static final String EXTRA = "cast_settings";
public static final int SETTINGS_VERSION = 3; public static final int SETTINGS_VERSION = 4;
public enum Quality { public enum Quality {
LOW(1_500_000, 24), LOW(1_500_000, 24),
@@ -49,11 +49,25 @@ public class CastSettings implements Serializable {
USER_CHOICE, USER_CHOICE,
/** Generated TV calibration pattern (no MediaProjection). */ /** Generated TV calibration pattern (no MediaProjection). */
CALIBRATION_TEST, CALIBRATION_TEST,
/** /** Camera2 rear camera as video source. */
* Future: Camera2 preview as video source (not MediaProjection app picker). CAMERA,
* {@link PlatformCastSupport#isCameraCaptureModeSupported()} /** Camera2 front camera as video source. */
*/ CAMERA_FRONT
CAMERA }
/** 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 { public enum Resolution {
@@ -78,13 +92,15 @@ public class CastSettings implements Serializable {
private String transport = CastConfig.DEFAULT_TRANSPORT; private String transport = CastConfig.DEFAULT_TRANSPORT;
private Quality quality = Quality.MEDIUM; private Quality quality = Quality.MEDIUM;
private Resolution resolution = Resolution.HD_720P; private Resolution resolution = Resolution.ADAPTIVE;
private boolean audioEnabled; private boolean audioEnabled;
private VideoCodec videoCodec = VideoCodec.AUTO; private VideoCodec videoCodec = VideoCodec.AUTO;
private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO; private AdjustmentPreset adjustmentPreset = AdjustmentPreset.AUTO;
private BitrateMode bitrateMode = BitrateMode.AUTO; private BitrateMode bitrateMode = BitrateMode.AUTO;
private NetworkAdoption networkAdoption = NetworkAdoption.AUTO; private NetworkAdoption networkAdoption = NetworkAdoption.AUTO;
private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode(); private CaptureMode captureMode = PlatformCastSupport.defaultCaptureMode();
private StreamProtection streamProtection = StreamProtection.AUTO;
private ReceiverDisplayResolution receiverDisplayResolution = ReceiverDisplayResolution.AUTO_FIT;
/** Filled after codec handshake (e.g. video/avc). */ /** Filled after codec handshake (e.g. video/avc). */
private transient String negotiatedVideoMime; private transient String negotiatedVideoMime;
@@ -200,4 +216,47 @@ public class CastSettings implements Serializable {
public void setCaptureMode(CaptureMode captureMode) { public void setCaptureMode(CaptureMode captureMode) {
this.captureMode = captureMode != null ? captureMode : CaptureMode.FULL_SCREEN; 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;
}
} }

View File

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

View File

@@ -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<CastSettings.VideoCodec> codecs = CodecCatalog.supportedChoices();
List<String> 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<String> labels = new ArrayList<>();
List<CastSettings.CaptureMode> 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<Boolean> 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<String> 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 <T extends Enum<T>> void bindEnumSpinner(Spinner spinner, T[] values, String[] labels,
T current, java.util.function.Consumer<T> 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 <T extends Enum<T>> void readEnumSpinner(Spinner spinner,
java.util.function.Consumer<T> 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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,27 +4,30 @@ import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.widget.Button; import android.widget.Button;
import androidx.appcompat.app.AppCompatActivity; import com.foxx.androidcast.receiver.ReceiverPlaybackActivity;
import com.foxx.androidcast.receiver.ReceiverActivity;
import com.foxx.androidcast.sender.SenderActivity; import com.foxx.androidcast.sender.SenderActivity;
import com.foxx.androidcast.stats.SessionStatsStore; 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 @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
SessionStatsStore.pruneOnAppStart(this); SessionStatsStore.pruneOnAppStart(this);
setContentView(R.layout.activity_main);
com.foxx.androidcast.network.CastTransportFactory.init(this); com.foxx.androidcast.network.CastTransportFactory.init(this);
PermissionHelper.request(this); PermissionHelper.request(this);
Button sendBtn = findViewById(R.id.btn_send); findViewById(R.id.btn_send).setOnClickListener(v ->
Button recvBtn = findViewById(R.id.btn_receive);
sendBtn.setOnClickListener(v ->
startActivity(new Intent(this, SenderActivity.class))); startActivity(new Intent(this, SenderActivity.class)));
recvBtn.setOnClickListener(v -> findViewById(R.id.btn_receive).setOnClickListener(v -> {
startActivity(new Intent(this, ReceiverActivity.class))); Intent i = new Intent(this, ReceiverPlaybackActivity.class);
i.putExtra(ReceiverPlaybackActivity.EXTRA_AUTO_START_LISTENING, true);
startActivity(i);
});
} }
@Override @Override

View File

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

View File

@@ -32,6 +32,29 @@ public final class PlatformCastSupport {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; 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. */ /** Applies default audio flag: on for Android 10+, off otherwise. */
public static void applyDefaultAudioSetting(CastSettings settings) { public static void applyDefaultAudioSetting(CastSettings settings) {
settings.setAudioEnabled(isAudioCaptureSupported()); settings.setAudioEnabled(isAudioCaptureSupported());

View File

@@ -8,6 +8,7 @@ import android.util.Log;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.DeviceInfo; import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.PinAuth;
import com.foxx.androidcast.network.CastCodecFlags; import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.CastPacketFramer; import com.foxx.androidcast.network.CastPacketFramer;
import com.foxx.androidcast.network.CastProtoHeader; import com.foxx.androidcast.network.CastProtoHeader;
@@ -53,13 +54,20 @@ public class DiscoveryManager {
public final int audioCodecs; public final int audioCodecs;
public final int placeholderId; public final int placeholderId;
public final long protoVersion; 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) { 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, public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
int videoCodecs, int audioCodecs, int placeholderId, long protoVersion) { 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.name = name;
this.host = host; this.host = host;
this.port = port; this.port = port;
@@ -69,6 +77,11 @@ public class DiscoveryManager {
this.audioCodecs = audioCodecs; this.audioCodecs = audioCodecs;
this.placeholderId = placeholderId; this.placeholderId = placeholderId;
this.protoVersion = protoVersion; 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, public void startAnnouncing(Context context, String deviceName, int port, String transport,
boolean listening) { 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)) { if (!running.compareAndSet(false, true)) {
return; 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) { 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() { private void browseLoop() {
@@ -203,7 +221,7 @@ public class DiscoveryManager {
} }
private void announceLoop(Context context, String deviceName, int port, String transport, private void announceLoop(Context context, String deviceName, int port, String transport,
boolean listening) { boolean listening, String pin) {
acquireMulticastLock(); acquireMulticastLock();
int placeholderId = DeviceInfo.getPlaceholderId(context); int placeholderId = DeviceInfo.getPlaceholderId(context);
int videoMask = CastCodecFlags.localDecoderVideoMask(); int videoMask = CastCodecFlags.localDecoderVideoMask();
@@ -213,7 +231,7 @@ public class DiscoveryManager {
sock.setBroadcast(true); sock.setBroadcast(true);
sock.setSoTimeout(400); sock.setSoTimeout(400);
while (running.get()) { while (running.get()) {
sendAnnounce(sock, context, deviceName, port, transport, listening, sendAnnounce(sock, context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask); placeholderId, videoMask, audioMask);
long listenUntil = System.currentTimeMillis() + 2_000; long listenUntil = System.currentTimeMillis() + 2_000;
while (running.get() && System.currentTimeMillis() < listenUntil) { while (running.get() && System.currentTimeMillis() < listenUntil) {
@@ -222,9 +240,9 @@ public class DiscoveryManager {
DatagramPacket packet = new DatagramPacket(buf, buf.length); DatagramPacket packet = new DatagramPacket(buf, buf.length);
sock.receive(packet); sock.receive(packet);
if (isDiscoverProbe(packet)) { if (isDiscoverProbe(packet)) {
sendAnnounce(sock, context, deviceName, port, transport, listening, sendAnnounce(sock, context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask); placeholderId, videoMask, audioMask);
sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask, packet.getAddress()); placeholderId, videoMask, audioMask, packet.getAddress());
} }
} catch (java.net.SocketTimeoutException ignored) { } catch (java.net.SocketTimeoutException ignored) {
@@ -269,9 +287,9 @@ public class DiscoveryManager {
} }
private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port, 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 { throws Exception {
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask); placeholderId, videoMask, audioMask);
InetAddress broadcast = InetAddress.getByName("255.255.255.255"); InetAddress broadcast = InetAddress.getByName("255.255.255.255");
DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT); 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, 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) { InetAddress target) {
try { try {
byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin,
placeholderId, videoMask, audioMask); placeholderId, videoMask, audioMask);
DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT); DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT);
sock.send(packet); sock.send(packet);
@@ -292,7 +310,7 @@ public class DiscoveryManager {
} }
private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport, 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(); JSONObject json = new JSONObject();
json.put("magic", CastConfig.DISCOVERY_MAGIC); json.put("magic", CastConfig.DISCOVERY_MAGIC);
json.put("name", deviceName); json.put("name", deviceName);
@@ -300,6 +318,9 @@ public class DiscoveryManager {
json.put("transport", transport); json.put("transport", transport);
json.put("listening", listening); json.put("listening", listening);
json.put("device_uuid", DeviceInfo.getDeviceUuid(context)); 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); byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask); CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
try { try {
@@ -364,9 +385,10 @@ public class DiscoveryManager {
} }
return; return;
} }
String pinAuth = json.optString("pin_auth", "");
String key = host + ":" + port; String key = host + ":" + port;
DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport, 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); devices.put(key, device);
notifyListener(); notifyListener();
} catch (Exception ignored) { } catch (Exception ignored) {

View File

@@ -80,6 +80,8 @@ public final class CastProtocol {
dos.writeInt(settings.getBitrateMode().ordinal()); dos.writeInt(settings.getBitrateMode().ordinal());
dos.writeInt(settings.getNetworkAdoption().ordinal()); dos.writeInt(settings.getNetworkAdoption().ordinal());
dos.writeInt(settings.getCaptureMode().ordinal()); dos.writeInt(settings.getCaptureMode().ordinal());
dos.writeInt(settings.getStreamProtection().ordinal());
dos.writeInt(settings.getReceiverDisplayResolution().ordinal());
dos.flush(); dos.flush();
return bos.toByteArray(); return bos.toByteArray();
} }
@@ -115,6 +117,15 @@ public final class CastProtocol {
settings.setCaptureMode(readEnum(CastSettings.CaptureMode.values(), dis.readInt(), settings.setCaptureMode(readEnum(CastSettings.CaptureMode.values(), dis.readInt(),
CastSettings.CaptureMode.FULL_SCREEN)); 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 { } else {
dis.reset(); dis.reset();
settings.setTransport(dis.readUTF()); settings.setTransport(dis.readUTF());

View File

@@ -8,31 +8,25 @@ import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.IBinder; import android.os.IBinder;
import android.os.RemoteException; import android.os.RemoteException;
import android.text.TextUtils;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.Button; import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.TextView; import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity; 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.CastSettings;
import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.PermissionHelper; import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.R; 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 { public class ReceiverActivity extends AppCompatActivity {
private final CastSettings castSettings = new CastSettings(); private final CastSettings castSettings = new CastSettings();
private TextView statusText; private TextView statusText;
private EditText pinInput;
private CheckBox allowAudioCheck;
private ICastReceiverService receiverService; private ICastReceiverService receiverService;
private boolean bound; private boolean bound;
@@ -93,19 +87,20 @@ public class ReceiverActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_receiver); setContentView(R.layout.activity_receiver);
PermissionHelper.request(this); PermissionHelper.request(this);
SettingsUi.bindReceiver(this, castSettings); castSettings.copyFrom(AppPreferences.loadReceiverDefaults(this));
statusText = findViewById(R.id.text_status); 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()); 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 @Override
protected void onStart() { protected void onStart() {
super.onStart(); super.onStart();
@@ -126,13 +121,6 @@ public class ReceiverActivity extends AppCompatActivity {
super.onStop(); super.onStop();
} }
@Override
protected void onResume() {
super.onResume();
PermissionHelper.remindIfMissing(this, allowAudioCheck.isChecked());
updateKeepScreenOn();
}
@Override @Override
protected void onPause() { protected void onPause() {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
@@ -148,11 +136,8 @@ public class ReceiverActivity extends AppCompatActivity {
} }
private void startReceiverService() { private void startReceiverService() {
String pin = pinInput.getText().toString().trim(); String pin = AppPreferences.getPin(this);
if (TextUtils.isEmpty(pin)) { boolean allowAudio = AppPreferences.isPlayIncomingAudio(this);
pin = CastConfig.DEFAULT_PIN;
}
boolean allowAudio = allowAudioCheck.isChecked();
ReceiverPrefs.setAllowAudio(this, allowAudio); ReceiverPrefs.setAllowAudio(this, allowAudio);
Intent intent = new Intent(this, ReceiverCastService.class); Intent intent = new Intent(this, ReceiverCastService.class);

View File

@@ -14,8 +14,11 @@ import android.os.RemoteException;
import android.util.Log; import android.util.Log;
import android.view.Surface; import android.view.Surface;
import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras; import com.foxx.androidcast.IntentExtras;
@@ -166,19 +169,23 @@ public class ReceiverCastService extends Service {
} }
if (intent != null && ACTION_START.equals(intent.getAction())) { if (intent != null && ACTION_START.equals(intent.getAction())) {
String pin = intent.getStringExtra(EXTRA_PIN); String pin = intent.getStringExtra(EXTRA_PIN);
if (pin == null || pin.isEmpty()) { if (pin == null) {
pin = com.foxx.androidcast.CastConfig.DEFAULT_PIN; pin = AppPreferences.getPin(this);
} }
CastSettings fromIntent = IntentExtras.getCastSettings(intent); CastSettings fromIntent = IntentExtras.getCastSettings(intent);
if (fromIntent != null) { if (fromIntent != null) {
settings = fromIntent; settings = fromIntent;
} else {
settings = AppPreferences.loadReceiverDefaults(this);
} }
allowIncomingAudio = intent.getBooleanExtra(EXTRA_ALLOW_AUDIO, allowIncomingAudio = intent.getBooleanExtra(EXTRA_ALLOW_AUDIO,
ReceiverPrefs.isAllowAudio(this)); AppPreferences.isPlayIncomingAudio(this));
activePin = pin; activePin = pin;
restartListening(pin); restartListening(pin);
} else if (intent == null && phase == Phase.IDLE) { } 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); restartListening(activePin);
} }
return START_STICKY; return START_STICKY;
@@ -203,6 +210,8 @@ public class ReceiverCastService extends Service {
acquireWakeLock(); acquireWakeLock();
startForeground(CastNotifications.ID_RECEIVER, startForeground(CastNotifications.ID_RECEIVER,
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class)); CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
CastActiveState.setReceiverListening(true);
CastTrayNotifier.refresh(this);
if (videoDecoder != null) { if (videoDecoder != null) {
videoDecoder.release(); videoDecoder.release();
@@ -592,8 +601,8 @@ public class ReceiverCastService extends Service {
discovery = new DiscoveryManager(this); discovery = new DiscoveryManager(this);
com.foxx.androidcast.network.CastTransportFactory.init(this); com.foxx.androidcast.network.CastTransportFactory.init(this);
int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport()); int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
discovery.startAnnouncing(android.os.Build.MODEL, announcePort, discovery.startAnnouncing(this, AppPreferences.getUsername(this), announcePort,
settings.getTransport(), true); settings.getTransport(), true, activePin);
Log.i(TAG, "Discovery announcing on port " + announcePort); Log.i(TAG, "Discovery announcing on port " + announcePort);
} }
@@ -878,6 +887,8 @@ public class ReceiverCastService extends Service {
private void releaseAll() { private void releaseAll() {
finishSessionStatsAsync(); finishSessionStatsAsync();
CastActiveState.setReceiverListening(false);
CastTrayNotifier.refresh(this);
phase = Phase.IDLE; phase = Phase.IDLE;
resetStreamState(); resetStreamState();
playbackLaunched = false; playbackLaunched = false;

View File

@@ -3,6 +3,7 @@ package com.foxx.androidcast.receiver;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Build;
import android.content.ServiceConnection; import android.content.ServiceConnection;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
@@ -21,9 +22,11 @@ import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity; import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastConfig; 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.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
@@ -37,12 +40,13 @@ import androidx.core.text.HtmlCompat;
* Fullscreen display: dancing-robot overlay while listening / waiting; * Fullscreen display: dancing-robot overlay while listening / waiting;
* video surface shown only after the first decoded frame. * 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_WIDTH = "video_width";
public static final String EXTRA_HEIGHT = "video_height"; public static final String EXTRA_HEIGHT = "video_height";
public static final String EXTRA_FINISH = "finish_playback"; public static final String EXTRA_FINISH = "finish_playback";
public static final String EXTRA_SHOW_AWAITING = "show_awaiting"; public static final String EXTRA_SHOW_AWAITING = "show_awaiting";
public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message"; public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message";
public static final String EXTRA_AUTO_START_LISTENING = "auto_start_listening";
private AspectRatioFrameLayout aspectContainer; private AspectRatioFrameLayout aspectContainer;
private TextureView textureView; 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 @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
if (handleCommandIntent(getIntent())) { if (handleCommandIntent(getIntent())) {
return; return;
} }
setContentView(R.layout.activity_playback);
applyFullscreen(); applyFullscreen();
readSizeFromIntent(getIntent()); readSizeFromIntent(getIntent());
@@ -154,12 +168,32 @@ public class ReceiverPlaybackActivity extends AppCompatActivity {
textureView.setSurfaceTextureListener(surfaceListener); textureView.setSurfaceTextureListener(surfaceListener);
applyAspectRatio(); 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); int msg = getIntent().getIntExtra(EXTRA_AWAITING_MESSAGE, R.string.playback_listening);
showAwaitingOverlay(msg); 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 @Override
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
super.onNewIntent(intent); super.onNewIntent(intent);

View File

@@ -224,6 +224,8 @@ public final class MultiCastCoordinator {
s.setBitrateMode(src.getBitrateMode()); s.setBitrateMode(src.getBitrateMode());
s.setNetworkAdoption(src.getNetworkAdoption()); s.setNetworkAdoption(src.getNetworkAdoption());
s.setCaptureMode(src.getCaptureMode()); s.setCaptureMode(src.getCaptureMode());
s.setStreamProtection(src.getStreamProtection());
s.setReceiverDisplayResolution(src.getReceiverDisplayResolution());
return s; return s;
} }
} }

View File

@@ -21,7 +21,9 @@ import android.util.Log;
import android.view.Surface; import android.view.Surface;
import android.view.WindowManager; import android.view.WindowManager;
import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastActiveState; import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution; 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_PORT = "port";
public static final String EXTRA_PIN = "pin"; public static final String EXTRA_PIN = "pin";
public static final String EXTRA_DEVICE_NAME = "device_name"; 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_TARGETS = "cast_targets";
public static final String EXTRA_CALIBRATION = "calibration_mode"; public static final String EXTRA_CALIBRATION = "calibration_mode";
public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND"; 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); String host = intent.getStringExtra(EXTRA_HOST);
int port = intent.getIntExtra(EXTRA_PORT, com.foxx.androidcast.CastConfig.CAST_PORT); int port = intent.getIntExtra(EXTRA_PORT, com.foxx.androidcast.CastConfig.CAST_PORT);
String pin = intent.getStringExtra(EXTRA_PIN); String pin = intent.getStringExtra(EXTRA_PIN);
if (pin == null) {
pin = "";
}
String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME); String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
String senderName = intent.getStringExtra(EXTRA_SENDER_NAME);
CastSettings settings = IntentExtras.getCastSettings(intent); CastSettings settings = IntentExtras.getCastSettings(intent);
boolean calibration = settings != null boolean calibration = settings != null
&& settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST; && settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
boolean camera = settings != null boolean camera = settings != null && settings.isCameraCapture();
&& settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA;
if (!calibration && !camera && data == null) { if (!calibration && !camera && data == null) {
failAndStop(getString(R.string.error_projection_data)); failAndStop(getString(R.string.error_projection_data));
return; return;
} }
if (host == null || pin == null || settings == null) { if (host == null || settings == null) {
failAndStop(getString(R.string.error_missing_params)); failAndStop(getString(R.string.error_missing_params));
return; 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; calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) { if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
failAndStop(getString(R.string.capture_camera_not_implemented)); 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)); updateStatus(getString(R.string.connecting_to, host));
multiCoordinator.connectAndHandshake(targets, settings, multiCoordinator.connectAndHandshake(targets, settings, senderName, pin);
deviceName != null ? deviceName : "Sender", pin);
List<MultiCastCoordinator.ActivePeer> peers = multiCoordinator.getPeers(); List<MultiCastCoordinator.ActivePeer> peers = multiCoordinator.getPeers();
if (peers.isEmpty()) { if (peers.isEmpty()) {
throw new IOException("No receivers connected"); throw new IOException("No receivers connected");
@@ -247,6 +255,7 @@ public class ScreenCastService extends Service implements
} }
casting.set(true); casting.set(true);
CastActiveState.setSenderCasting(true); CastActiveState.setSenderCasting(true);
CastTrayNotifier.refresh(this);
activeSettings = settings; activeSettings = settings;
sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport()); sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport());
sessionStatsRecorder.mergeSettings(settings); sessionStatsRecorder.mergeSettings(settings);
@@ -382,7 +391,8 @@ public class ScreenCastService extends Service implements
} }
cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this); cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this);
try { try {
cameraCapture.start(surface, captureWidth, captureHeight); cameraCapture.start(surface, captureWidth, captureHeight, activeSettings != null
&& activeSettings.isFrontCamera());
} catch (android.hardware.camera2.CameraAccessException e) { } catch (android.hardware.camera2.CameraAccessException e) {
throw new IOException("Camera open failed: " + e.getMessage(), e); throw new IOException("Camera open failed: " + e.getMessage(), e);
} }
@@ -790,6 +800,7 @@ public class ScreenCastService extends Service implements
stopping.set(true); stopping.set(true);
casting.set(false); casting.set(false);
CastActiveState.setSenderCasting(false); CastActiveState.setSenderCasting(false);
CastTrayNotifier.refresh(this);
networkFeedback = null; networkFeedback = null;
calibrationMode = false; calibrationMode = false;
cameraMode = false; cameraMode = false;

View File

@@ -2,16 +2,12 @@ package com.foxx.androidcast.sender;
import android.app.Activity; import android.app.Activity;
import android.content.Intent; import android.content.Intent;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.media.projection.MediaProjectionConfig; import android.media.projection.MediaProjectionConfig;
import android.media.projection.MediaProjectionManager; import android.media.projection.MediaProjectionManager;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.provider.Settings;
import android.text.TextUtils;
import android.view.View; import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.Button; import android.widget.Button;
@@ -22,33 +18,37 @@ import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.OnBackPressedCallback; 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.CastActiveState;
import com.foxx.androidcast.CastActivityTracker;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.sender.CastReceiverTarget;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.PermissionHelper; import com.foxx.androidcast.PermissionHelper;
import com.foxx.androidcast.PlatformCastSupport; import com.foxx.androidcast.PlatformCastSupport;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.SettingsUi;
import com.foxx.androidcast.discovery.DiscoveryManager; 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 int REQUEST_MEDIA_PROJECTION = 2001;
private static final long BACK_STOP_INTERVAL_MS = 2_000; private static final int REQUEST_PREVIEW_PROJECTION = 2002;
private static final int DEV_TAP_UNLOCK = 7;
private final CastSettings castSettings = new CastSettings(); private final CastSettings castSettings = new CastSettings();
private final Handler handler = new Handler(Looper.getMainLooper()); private final Handler handler = new Handler(Looper.getMainLooper());
private DiscoveryManager discovery; private DiscoveryManager discovery;
private DeviceListAdapter deviceAdapter; private DeviceListAdapter deviceAdapter;
private SenderCapturePreview capturePreview;
private SenderScreenPreview screenPreview;
private TextView statusText; private TextView statusText;
private TextView titleText;
private ProgressBar discoveryProgress; private ProgressBar discoveryProgress;
private Button refreshButton; private EditText pinInput;
private View advancedPanel; private SwitchMaterial muteSwitch;
private Button startButton;
private boolean pinLocked;
private enum DiscoveryPhase { private enum DiscoveryPhase {
IDLE, SCANNING, PAUSED IDLE, SCANNING, PAUSED
@@ -57,62 +57,55 @@ public class SenderActivity extends AppCompatActivity {
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE; private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
private int discoverySecondsLeft; private int discoverySecondsLeft;
private final Runnable autoScanRunnable = () -> beginScanPass(false); 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 String pendingPin;
private long lastTitleTapMs; private String pendingHost;
private boolean useManualConnect; private int pendingPort;
private long lastBackPressMs; private String pendingDeviceName;
private java.util.ArrayList<CastReceiverTarget> pendingTargets;
private boolean pendingCameraLaunch;
private boolean previewConsentDeclined;
private boolean previewConsentInFlight;
@Override
protected int getContentLayoutId() {
return R.layout.activity_sender;
}
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sender);
PermissionHelper.request(this); PermissionHelper.request(this);
SettingsUi.bindSender(this, castSettings);
titleText = findViewById(R.id.text_sender_title); castSettings.copyFrom(AppPreferences.loadSenderDefaults(this));
pinInput = findViewById(R.id.edit_pin); screenPreview = new SenderScreenPreview(this);
pinInput.setText(CastConfig.DEFAULT_PIN);
statusText = findViewById(R.id.text_status); 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); 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 -> { pinInput.setText(AppPreferences.getPin(this));
long now = System.currentTimeMillis(); muteSwitch.setChecked(!castSettings.isAudioRequested());
if (now - lastTitleTapMs > 2000) { muteSwitch.setOnCheckedChangeListener((btn, muted) ->
titleTapCount = 0; 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++; findViewById(R.id.text_preview_label).setOnClickListener(requestPreviewConsent);
if (titleTapCount >= DEV_TAP_UNLOCK) { findViewById(R.id.texture_preview).setOnClickListener(requestPreviewConsent);
titleTapCount = 0; refreshPreview();
advancedPanel.setVisibility( maybeRequestPreviewConsent();
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();
}
});
ListView listView = findViewById(R.id.list_devices); ListView listView = findViewById(R.id.list_devices);
listView.setChoiceMode(CastConfig.MULTI_RECEIVER_ENABLED listView.setChoiceMode(CastConfig.MULTI_RECEIVER_ENABLED
@@ -121,7 +114,6 @@ public class SenderActivity extends AppCompatActivity {
listView.setAdapter(deviceAdapter); listView.setAdapter(deviceAdapter);
listView.setOnItemClickListener((parent, view, position, id) -> { listView.setOnItemClickListener((parent, view, position, id) -> {
deviceAdapter.setSelectedPosition(position); deviceAdapter.setSelectedPosition(position);
useManualConnect = false;
java.util.List<DiscoveryManager.DiscoveredDevice> selectedList = java.util.List<DiscoveryManager.DiscoveredDevice> selectedList =
deviceAdapter.getSelectedDevices(); deviceAdapter.getSelectedDevices();
if (selectedList.isEmpty()) { if (selectedList.isEmpty()) {
@@ -158,18 +150,14 @@ public class SenderActivity extends AppCompatActivity {
public void onScanStarted() { public void onScanStarted() {
discoveryPhase = DiscoveryPhase.SCANNING; discoveryPhase = DiscoveryPhase.SCANNING;
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000); discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
updateRefreshButtonLabel(); discoveryProgress.setVisibility(View.VISIBLE);
handler.removeCallbacks(discoveryCountdownTick);
handler.post(discoveryCountdownTick);
showDiscoveryProgress();
} }
@Override @Override
public void onScanEnded() { public void onScanEnded() {
hideDiscoveryProgress();
discoveryPhase = DiscoveryPhase.PAUSED; discoveryPhase = DiscoveryPhase.PAUSED;
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000); discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000);
updateRefreshButtonLabel(); discoveryProgress.setVisibility(View.GONE);
handler.removeCallbacks(autoScanRunnable); handler.removeCallbacks(autoScanRunnable);
handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS); handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS);
} }
@@ -179,35 +167,73 @@ public class SenderActivity extends AppCompatActivity {
@Override @Override
public void handleOnBackPressed() { public void handleOnBackPressed() {
if (CastActiveState.isSenderCasting()) { if (CastActiveState.isSenderCasting()) {
long now = System.currentTimeMillis(); Toast.makeText(SenderActivity.this, R.string.cast_in_progress_pin_locked,
if (now - lastBackPressMs < BACK_STOP_INTERVAL_MS) { Toast.LENGTH_SHORT).show();
stopCastingAndExit();
} else {
lastBackPressMs = now;
Toast.makeText(SenderActivity.this,
R.string.press_back_again_to_stop_cast, Toast.LENGTH_SHORT).show();
}
} else { } else {
finish(); finish();
} }
} }
}); });
refreshButton.setOnClickListener(v -> onRefreshClicked()); startButton.setOnClickListener(v ->
findViewById(R.id.btn_start_cast).setOnClickListener(v ->
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow)); PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow));
findViewById(R.id.btn_manual_cast).setOnClickListener(v -> {
useManualConnect = true;
PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow);
});
} }
@Override @Override
protected void onResume() { protected void onResume() {
super.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()); 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 @Override
protected void onStart() { protected void onStart() {
super.onStart(); super.onStart();
@@ -216,25 +242,14 @@ public class SenderActivity extends AppCompatActivity {
@Override @Override
protected void onStop() { protected void onStop() {
handler.removeCallbacks(discoveryCountdownTick);
handler.removeCallbacks(autoScanRunnable); handler.removeCallbacks(autoScanRunnable);
if (discovery != null) { if (discovery != null) {
discovery.stop(); discovery.stop();
} }
discoveryPhase = DiscoveryPhase.IDLE; discoveryPhase = DiscoveryPhase.IDLE;
updateRefreshButtonLabel();
hideDiscoveryProgress();
super.onStop(); super.onStop();
} }
private void onRefreshClicked() {
if (discoveryPhase == DiscoveryPhase.SCANNING) {
return;
}
handler.removeCallbacks(autoScanRunnable);
beginScanPass(true);
}
private void beginScanPass(boolean userInitiated) { private void beginScanPass(boolean userInitiated) {
if (discovery != null && discovery.isBrowsing()) { if (discovery != null && discovery.isBrowsing()) {
return; return;
@@ -254,42 +269,6 @@ public class SenderActivity extends AppCompatActivity {
discovery.startBrowsing(); 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() { private void startCastFlow() {
PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled()); PermissionHelper.remindIfMissing(this, castSettings.isAudioEnabled());
if (!PermissionHelper.hasAll(this)) { 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(); Toast.makeText(this, R.string.codec_passthrough_unavailable, Toast.LENGTH_LONG).show();
return; return;
} }
String pin = pinInput.getText().toString().trim(); String pin = pinInput.getText().toString().trim();
if (TextUtils.isEmpty(pin)) { if (!AppPreferences.getPin(this).equals(pin)) {
Toast.makeText(this, R.string.enter_pin, Toast.LENGTH_SHORT).show(); AppPreferences.setPin(this, pin);
}
java.util.List<DiscoveryManager.DiscoveredDevice> 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; return;
} }
if (useManualConnect) { pendingTargets = new java.util.ArrayList<>();
String host = manualHostInput.getText().toString().trim(); for (DiscoveryManager.DiscoveredDevice d : selectedList) {
String portStr = manualPortInput.getText().toString().trim(); if (!CastConfig.MULTI_RECEIVER_ENABLED
if (TextUtils.isEmpty(host) || TextUtils.isEmpty(portStr)) { && !castSettings.getTransport().equals(d.transport)) {
Toast.makeText(this, R.string.manual_connect_missing, Toast.LENGTH_SHORT).show(); Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
return; return;
} }
pendingHost = host; pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
try { d.videoCodecs, d.audioCodecs, d.placeholderId));
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<DiscoveryManager.DiscoveredDevice> 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;
} }
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; pendingPin = pin;
proceedCaptureConsent();
}
private void showPinMismatchDialog(java.util.List<DiscoveryManager.DiscoveredDevice> 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) { if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
launchCastService(null, Activity.RESULT_OK); launchCastService(null, Activity.RESULT_OK);
return; return;
} }
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) { if (castSettings.isCameraCapture()) {
if (androidx.core.content.ContextCompat.checkSelfPermission(this, if (androidx.core.content.ContextCompat.checkSelfPermission(this,
android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) { android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
androidx.core.app.ActivityCompat.requestPermissions(this, androidx.core.app.ActivityCompat.requestPermissions(this,
@@ -365,6 +374,11 @@ public class SenderActivity extends AppCompatActivity {
launchCastService(null, Activity.RESULT_OK); launchCastService(null, Activity.RESULT_OK);
return; return;
} }
if (screenPreview.hasConsent()) {
screenPreview.stop();
launchCastService(screenPreview.getProjectionData(), screenPreview.getProjectionResultCode());
return;
}
MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION); startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION);
} }
@@ -378,14 +392,6 @@ public class SenderActivity extends AppCompatActivity {
return mgr.createScreenCaptureIntent(); return mgr.createScreenCaptureIntent();
} }
private boolean pendingCameraLaunch;
private String pendingPin;
private String pendingHost;
private int pendingPort;
private String pendingDeviceName;
private java.util.ArrayList<CastReceiverTarget> pendingTargets;
@Override @Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults); super.onRequestPermissionsResult(requestCode, permissions, grantResults);
@@ -402,6 +408,17 @@ public class SenderActivity extends AppCompatActivity {
@Override @Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) { protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, 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) { if (requestCode != REQUEST_MEDIA_PROJECTION) {
return; return;
} }
@@ -409,14 +426,19 @@ public class SenderActivity extends AppCompatActivity {
Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show();
return; return;
} }
screenPreview.setProjectionResult(resultCode, data);
launchCastService(data, resultCode); launchCastService(data, resultCode);
} }
private void launchCastService(Intent projectionData, int resultCode) { private void launchCastService(Intent projectionData, int resultCode) {
String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME); if (screenPreview != null) {
if (deviceName == null) { screenPreview.stop();
deviceName = Build.MODEL;
} }
pinLocked = true;
pinInput.setEnabled(false);
startButton.setEnabled(false);
muteSwitch.setEnabled(false);
Intent serviceIntent = new Intent(this, ScreenCastService.class); Intent serviceIntent = new Intent(this, ScreenCastService.class);
serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode);
if (projectionData != null) { if (projectionData != null) {
@@ -424,8 +446,9 @@ public class SenderActivity extends AppCompatActivity {
} }
serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost); serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost);
serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort); serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort);
serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin); serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin != null ? pendingPin : "");
serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName); serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName);
serviceIntent.putExtra(ScreenCastService.EXTRA_SENDER_NAME, AppPreferences.getUsername(this));
serviceIntent.putExtra(CastSettings.EXTRA, castSettings); serviceIntent.putExtra(CastSettings.EXTRA, castSettings);
if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) { if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) {
serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true); serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true);

View File

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

View File

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

View File

@@ -32,16 +32,17 @@ public final class CameraCastCapture {
appContext = context.getApplicationContext(); 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(); stop();
thread = new HandlerThread("CameraCast"); thread = new HandlerThread("CameraCast");
thread.start(); thread.start();
handler = new Handler(thread.getLooper()); handler = new Handler(thread.getLooper());
CameraManager mgr = appContext.getSystemService(CameraManager.class); CameraManager mgr = appContext.getSystemService(CameraManager.class);
cameraId = chooseCameraId(mgr); cameraId = chooseCameraId(mgr, useFrontCamera);
if (cameraId == null) { if (cameraId == null) {
throw new CameraAccessException(CameraAccessException.CAMERA_ERROR, 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); Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight);
Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight()); Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight());
@@ -97,11 +98,14 @@ public final class CameraCastCapture {
}, handler); }, 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()) { for (String id : mgr.getCameraIdList()) {
CameraCharacteristics chars = mgr.getCameraCharacteristics(id); CameraCharacteristics chars = mgr.getCameraCharacteristics(id);
Integer facing = chars.get(CameraCharacteristics.LENS_FACING); Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) { if (facing != null && facing == want) {
return id; return id;
} }
} }

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="10dp" />
<gradient
android:angle="0"
android:endColor="@color/settings_header_gradient_end"
android:startColor="@color/settings_header_gradient_start"
android:type="linear" />
</shape>
</item>
<item android:gravity="bottom">
<shape android:shape="rectangle">
<corners
android:bottomLeftRadius="10dp"
android:bottomRightRadius="10dp"
android:topLeftRadius="0dp"
android:topRightRadius="0dp" />
<size android:height="4dp" />
<solid android:color="@color/settings_header_shadow" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/text_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/searching" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:orientation="horizontal">
<ListView
android:id="@+id/list_devices"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="0.32"
android:choiceMode="singleChoice" />
<FrameLayout
android:id="@+id/panel_preview"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_weight="0.68"
android:background="#101010">
<TextureView
android:id="@+id/texture_preview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<TextView
android:id="@+id/text_preview_label"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:padding="16dp"
android:text="@string/preview_placeholder"
android:textColor="#CCCCCC" />
</FrameLayout>
</LinearLayout>
<EditText
android:id="@+id/edit_pin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/pin_hint"
android:inputType="numberPassword"
android:maxLength="32" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_horizontal"
android:orientation="horizontal">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_mute"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false"
android:text="@string/label_mute" />
<Button
android:id="@+id/btn_start_cast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:minWidth="120dp"
android:text="@string/start_cast" />
</LinearLayout>
<FrameLayout
android:id="@+id/panel_advanced"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progress_discovery"
style="?android:attr/progressBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,239 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/cast_settings_title"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/settings_section_global"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_username" />
<EditText
android:id="@+id/edit_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:maxLines="1" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/pin_hint" />
<EditText
android:id="@+id/edit_settings_pin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/pin_anonymous_hint"
android:inputType="numberPassword"
android:maxLength="32" />
<CheckBox
android:id="@+id/check_tray_icon"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="true"
android:text="@string/label_show_tray_icon" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/settings_section_sender"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_capture_mode" />
<Spinner
android:id="@+id/spinner_sender_capture_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_resolution" />
<Spinner
android:id="@+id/spinner_sender_resolution"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_network_adoption" />
<Spinner
android:id="@+id/spinner_sender_network"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_bitrate_mode" />
<Spinner
android:id="@+id/spinner_sender_bitrate"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_video_codec" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/codec_hint_negotiated"
android:textSize="12sp" />
<Spinner
android:id="@+id/spinner_sender_video_codec"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_transport" />
<Spinner
android:id="@+id/spinner_sender_transport"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_audio" />
<Spinner
android:id="@+id/spinner_sender_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/text_sender_audio_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:visibility="gone" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_stream_protection" />
<TextView
android:id="@+id/text_stream_protection_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stream_protection_hint"
android:textSize="12sp" />
<Spinner
android:id="@+id/spinner_sender_stream_protection"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/settings_section_receiver"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_display" />
<Spinner
android:id="@+id/spinner_receiver_resolution"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_transport" />
<Spinner
android:id="@+id/spinner_receiver_transport"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_network_adoption" />
<Spinner
android:id="@+id/spinner_receiver_network"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_video_codec" />
<Spinner
android:id="@+id/spinner_receiver_video_codec"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_audio" />
<Spinner
android:id="@+id/spinner_receiver_play_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<FrameLayout
android:id="@+id/content_root"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/settings_drawer_container"
android:layout_width="320dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="?android:attr/colorBackground"
android:elevation="8dp"
android:fitsSystemWindows="true" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -25,6 +25,7 @@
android:id="@+id/btn_receive" android:id="@+id/btn_receive"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="@string/receive_screen" /> android:text="@string/receive_screen" />
<TextView <TextView
@@ -33,4 +34,12 @@
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:text="@string/main_hint" android:text="@string/main_hint"
android:textSize="14sp" /> android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:text="@string/swipe_for_settings"
android:textSize="13sp" />
</LinearLayout> </LinearLayout>

View File

@@ -1,49 +1,39 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:padding="16dp"> android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<LinearLayout <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:gravity="center"
android:text="@string/receiver_title"
android:textSize="20sp"
android:textStyle="bold" />
<TextView <TextView
android:layout_width="match_parent" android:id="@+id/text_status"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:text="@string/receiver_title" android:layout_height="wrap_content"
android:textSize="18sp" android:layout_marginTop="16dp"
android:textStyle="bold" /> android:gravity="center"
android:text="@string/waiting_sender" />
<include layout="@layout/activity_settings_panel" /> <Button
android:id="@+id/btn_start_receiver"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/start_receiver" />
<EditText <TextView
android:id="@+id/edit_pin" android:layout_width="match_parent"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:layout_marginTop="16dp"
android:hint="@string/pin_hint" android:gravity="center"
android:inputType="numberPassword" /> android:text="@string/receiver_use_settings_hint"
android:textSize="13sp" />
<CheckBox </LinearLayout>
android:id="@+id/check_allow_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_audio" />
<Button
android:id="@+id/btn_start_receiver"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/start_receiver" />
<TextView
android:id="@+id/text_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/waiting_sender" />
</LinearLayout>
</ScrollView>

View File

@@ -1,126 +1,100 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/text_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="@string/searching" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="0dp"
android:orientation="vertical" android:layout_marginTop="8dp"
android:padding="16dp"> android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/text_sender_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sender_title"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/searching" />
<include layout="@layout/activity_settings_panel" />
<LinearLayout
android:id="@+id/panel_advanced"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/advanced_connect_title"
android:textStyle="bold" />
<EditText
android:id="@+id/edit_manual_host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/manual_host_hint"
android:inputType="text" />
<EditText
android:id="@+id/edit_manual_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/manual_port_hint"
android:inputType="number"
android:text="41235" />
<EditText
android:id="@+id/edit_gateway_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:enabled="false"
android:hint="@string/gateway_url_hint"
android:inputType="textUri" />
<Button
android:id="@+id/btn_manual_cast"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/manual_cast" />
</LinearLayout>
<EditText
android:id="@+id/edit_pin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/pin_hint"
android:inputType="numberPassword"
android:maxLength="16" />
<ListView <ListView
android:id="@+id/list_devices" android:id="@+id/list_devices"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="200dp" android:layout_height="0dp"
android:layout_marginTop="8dp" /> android:layout_weight="0.4"
android:choiceMode="singleChoice" />
<LinearLayout <FrameLayout
android:id="@+id/panel_preview"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="0dp"
android:orientation="horizontal"> android:layout_marginTop="8dp"
android:layout_weight="0.6"
android:background="#101010">
<FrameLayout <TextureView
android:layout_width="0dp" android:id="@+id/texture_preview"
android:layout_height="wrap_content" android:layout_width="match_parent"
android:layout_marginEnd="8dp" android:layout_height="match_parent"
android:layout_weight="1"> android:visibility="gone" />
<Button <TextView
android:id="@+id/btn_refresh" android:id="@+id/text_preview_label"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:text="@string/refresh" /> android:gravity="center"
android:padding="16dp"
<ProgressBar android:text="@string/preview_placeholder"
android:id="@+id/progress_discovery" android:textColor="#CCCCCC" />
style="?android:attr/progressBarStyleHorizontal" </FrameLayout>
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:alpha="0.85"
android:clickable="false"
android:focusable="false"
android:indeterminate="true"
android:visibility="gone" />
</FrameLayout>
<Button
android:id="@+id/btn_start_cast"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/start_cast" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView>
<EditText
android:id="@+id/edit_pin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/pin_hint"
android:inputType="numberPassword"
android:maxLength="32" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_horizontal"
android:orientation="horizontal">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_mute"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false"
android:text="@string/label_mute" />
<Button
android:id="@+id/btn_start_cast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:minWidth="120dp"
android:text="@string/start_cast" />
</LinearLayout>
<FrameLayout
android:id="@+id/panel_advanced"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progress_discovery"
style="?android:attr/progressBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,248 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:text="@string/cast_settings_title"
android:textSize="20sp"
android:textStyle="bold" />
<include
layout="@layout/include_settings_section_header"
android:id="@+id/header_global"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_username" />
<EditText
android:id="@+id/edit_username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:maxLines="1" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/pin_hint" />
<EditText
android:id="@+id/edit_settings_pin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/pin_anonymous_hint"
android:inputType="numberPassword"
android:maxLength="32" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_theme" />
<Spinner
android:id="@+id/spinner_theme"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<CheckBox
android:id="@+id/check_tray_icon"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="true"
android:text="@string/label_show_tray_icon" />
<include
layout="@layout/include_settings_section_header"
android:id="@+id/header_sender"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_capture_mode" />
<Spinner
android:id="@+id/spinner_sender_capture_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_resolution" />
<Spinner
android:id="@+id/spinner_sender_resolution"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_network_adoption" />
<Spinner
android:id="@+id/spinner_sender_network"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_bitrate_mode" />
<Spinner
android:id="@+id/spinner_sender_bitrate"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_video_codec" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/codec_hint_negotiated"
android:textSize="12sp" />
<Spinner
android:id="@+id/spinner_sender_video_codec"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_transport" />
<Spinner
android:id="@+id/spinner_sender_transport"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_audio" />
<Spinner
android:id="@+id/spinner_sender_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/text_sender_audio_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:visibility="gone" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_stream_protection" />
<TextView
android:id="@+id/text_stream_protection_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/stream_protection_hint"
android:textSize="12sp" />
<Spinner
android:id="@+id/spinner_sender_stream_protection"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<include
layout="@layout/include_settings_section_header"
android:id="@+id/header_receiver"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_display" />
<Spinner
android:id="@+id/spinner_receiver_resolution"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_transport" />
<Spinner
android:id="@+id/spinner_receiver_transport"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_network_adoption" />
<Spinner
android:id="@+id/spinner_receiver_network"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_video_codec" />
<Spinner
android:id="@+id/spinner_receiver_video_codec"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_audio" />
<Spinner
android:id="@+id/spinner_receiver_play_audio"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_settings_section_header"
android:elevation="4dp"
android:paddingStart="14dp"
android:paddingTop="12dp"
android:paddingEnd="14dp"
android:paddingBottom="14dp"
android:textColor="@color/settings_header_text"
android:textSize="18sp"
android:textStyle="bold" />

View File

@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="device_item_selected">#661565C0</color> <!-- Section headers: blue (left) → black (right) -->
<color name="settings_header_gradient_start">#FF1565C0</color>
<color name="settings_header_gradient_end">#FF000000</color>
<color name="settings_header_text">#FFFFFFFF</color>
<color name="settings_header_shadow">#99000000</color>
</resources> </resources>

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#0D47A1</color> <color name="ic_launcher_background">#1565C0</color>
<color name="device_item_selected">#4D1565C0</color> <color name="device_item_selected">#331565C0</color>
<!-- Section headers: blue (left) → dark (right) -->
<color name="settings_header_gradient_start">#FF1565C0</color>
<color name="settings_header_gradient_end">#FF212121</color>
<color name="settings_header_text">#FFFFFFFF</color>
<color name="settings_header_shadow">#66000000</color>
<color name="inverted_window_bg">#FFF5F5F5</color>
<color name="inverted_primary">#FF212121</color>
</resources> </resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item type="id" name="tag_spinner_values" />
<item type="id" name="tag_spinner_enum" />
</resources>

View File

@@ -121,4 +121,50 @@
<string name="audio_skipped">Video only (audio skipped)</string> <string name="audio_skipped">Video only (audio skipped)</string>
<string name="casting_rotated">Rotated — now %1$dx%2$d</string> <string name="casting_rotated">Rotated — now %1$dx%2$d</string>
<string name="waiting_receiver">Waiting for receiver at %1$s:%2$d…</string> <string name="waiting_receiver">Waiting for receiver at %1$s:%2$d…</string>
<string name="open_settings">Settings</string>
<string name="cast_settings_title">Android Cast settings</string>
<string name="settings_section_global">Global app settings</string>
<string name="settings_section_sender">Sender settings</string>
<string name="settings_section_receiver">Receiver settings</string>
<string name="label_username">Username</string>
<string name="label_show_tray_icon">Show tray icon while cast service is running</string>
<string name="pin_anonymous_hint">Leave empty for anonymous cast</string>
<string name="label_receiver_display">Resolution</string>
<string name="receiver_display_auto_fit">AUTO (fits screen)</string>
<string name="receiver_display_adaptive">Adaptive (experimental)</string>
<string name="label_stream_protection">Stream protection (UDP)</string>
<string name="stream_protection_hint">Hint only — negotiated with receiver. UDP transports only.</string>
<string name="protection_fec_34">FEC 3/4</string>
<string name="protection_fec_rs">FEC (Reed-Solomon)</string>
<string name="protection_nack">NACK</string>
<string name="protection_fec_nack">FEC + NACK</string>
<string name="network_estimated_auto">Estimated (auto)</string>
<string name="bitrate_estimated">Estimated</string>
<string name="option_on">ON</string>
<string name="option_off">OFF</string>
<string name="codec_hint_negotiated">Priority hint — codec is negotiated during handshake.</string>
<string name="capture_camera_rear">Camera (rear)</string>
<string name="capture_camera_front">Camera (front)</string>
<string name="label_mute">Mute</string>
<string name="preview_placeholder">Source preview\n(screen capture starts after START)</string>
<string name="preview_camera">Camera preview</string>
<string name="preview_calibration">Calibration test pattern</string>
<string name="receiver_use_settings_hint">Stream options are in Android Cast settings.</string>
<string name="pin_mismatch_title">PIN mismatch</string>
<string name="pin_mismatch_message">The receiver expects a different PIN. Enter the PIN for this cast session.</string>
<string name="pin_session_hint">Session PIN</string>
<string name="settings_saved">Settings saved</string>
<string name="cast_in_progress_pin_locked">PIN cannot be changed while casting</string>
<string name="tray_cast_active">Cast active — tap to open app</string>
<string name="tray_cast_idle">Android Cast — tap to open app</string>
<string name="tray_channel_name">Cast tray</string>
<string name="tray_channel_description">Persistent cast status icon in the notification bar</string>
<string name="swipe_for_settings">Swipe from the left edge for settings</string>
<string name="label_theme">Theme</string>
<string name="theme_system">System (default)</string>
<string name="theme_dark">Dark</string>
<string name="theme_light">Light</string>
<string name="theme_inverted">Inverted</string>
<string name="theme_apply_restart">Theme updated</string>
<string name="preview_tap_for_screen">Screen preview — grant capture when prompted</string>
</resources> </resources>

View File

@@ -17,4 +17,26 @@
<item name="android:windowIsTranslucent">true</item> <item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowBackground">@android:color/transparent</item>
</style> </style>
<style name="Theme.AndroidCast.Light" parent="Theme.MaterialComponents.Light.DarkActionBar">
<item name="colorPrimary">#1565C0</item>
<item name="colorPrimaryVariant">#0D47A1</item>
<item name="colorOnPrimary">#FFFFFF</item>
<item name="colorSecondary">#26A69A</item>
</style>
<style name="Theme.AndroidCast.Dark" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">#1565C0</item>
<item name="colorPrimaryVariant">#0D47A1</item>
<item name="colorOnPrimary">#FFFFFF</item>
<item name="colorSecondary">#26A69A</item>
</style>
<style name="Theme.AndroidCast.Inverted" parent="Theme.MaterialComponents.Light.DarkActionBar">
<item name="colorPrimary">@color/inverted_primary</item>
<item name="colorPrimaryVariant">#000000</item>
<item name="colorOnPrimary">#FFFFFF</item>
<item name="colorSecondary">#455A64</item>
<item name="android:windowBackground">@color/inverted_window_bg</item>
</style>
</resources> </resources>