+**Professional Android multi-camera production, recording and streaming suite.**
-
+FadCam Studio extends the FadCam camera engine with a broadcast-style production room for camera switching, program/preview monitoring, recording, graphics, audio controls, replay and remote production workflows.
+## 📱 Verified Studio APK
-
-
+## 🌐 Streaming Review & Social Live
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
](https://github.com/anonfaded/FadCam/releases)
-
-Or:
-
-
-
-
-
-
-
-
-
-
-
-## `>_` Contributions
-
-We welcome any contributions to improve this project! Whether it's bug fixes or new features, your help is appreciated.
-
-### `>_` How to Contribute
-1. **Open an Issue First**: Before starting any work, please open an [issue](https://github.com/anonfaded/FadCam/issues) to discuss your proposed changes and get approval.
-2. **Fork the Repo**: Fork the repository to make your changes.
-3. **Submit a PR**: Create a pull request with a clear description of your changes.
-
-We look forward to your contributions!
-
-
-### `>_` 🙌 Contributions & Credits
-
-- 🖼️ App screenshots and banner design by [T010](https://ko-fi.com/t010nl)
-- The 3D text in icon is from Font generator.
-- Icons used in app by Icons8 and Flaticon.
-- Elapsed timer font option includes Doto by The Doto Project Authors (https://github.com/oliverlalan/Doto).
-- Location reverse geocoding powered by Nominatim — free, open-source geocoding based on OpenStreetMap data. Building-level precision (zoom=18) for accurate address watermarking.
-- QR codes generated using QRCodeChimp.
+For a local release APK, provide `KEYSTORE_FILE`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, and `KEY_PASSWORD` in `local.properties`. The release build does not fall back to the Android debug keystore.
+## ⚠️ Installation / Play Protect
+Play Protect independently scans sideloaded applications. For production distribution, use a trusted channel such as Google Play and a protected production signing key.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 136404467..429b3b098 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,6 @@ android {
splits {
abi {
- // For pro builds: enable splits but only arm64-v8a (no universal)
- // For main builds: arm64-v8a + armeabi-v7a with universal APK
isEnable = !isBundle
reset()
if (isProBuild) {
@@ -35,22 +33,14 @@ android {
versionCode = 52
versionName = "4.0.0"
vectorDrawables.useSupportLibrary = true
-
- // Fix 16KB native library alignment for Android 15
- // Generate full native debug symbols so they can be uploaded to Play Console
- ndk {
- debugSymbolLevel = "FULL"
- }
+ ndk { debugSymbolLevel = "FULL" }
}
signingConfigs {
create("release") {
val props = Properties()
- rootProject.file("local.properties").takeIf { it.exists() }?.inputStream().use { stream ->
- stream?.let { props.load(it) }
- }
+ rootProject.file("local.properties").takeIf { it.exists() }?.inputStream().use { stream -> stream?.let { props.load(it) } }
val keystoreFile = props.getProperty("KEYSTORE_FILE", "")
- // Only set storeFile if keystore file path is provided and exists
if (keystoreFile.isNotEmpty() && file(keystoreFile).exists()) {
storeFile = file(keystoreFile)
storePassword = props.getProperty("KEYSTORE_PASSWORD", "")
@@ -59,195 +49,77 @@ android {
}
}
}
-
- // Helper: check if release signing config is valid
+
val releaseSigningConfigValid = signingConfigs.getByName("release").storeFile != null
buildTypes {
debug {
- applicationIdSuffix = ".beta"
- isDebuggable = true
- versionNameSuffix = "-beta10.6" // Increment the beta version suffix for each release. Use `beta1` for the first beta release, then `beta2`, etc.
- resValue("string", "app_name", "FadCam Beta")
+ applicationIdSuffix = ".beta"; isDebuggable = true; versionNameSuffix = "-beta10.6"; resValue("string", "app_name", "FadCam Beta")
}
-
release {
- isMinifyEnabled = true
- isShrinkResources = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- isDebuggable = false
- signingConfig = signingConfigs.getByName("release")
+ isMinifyEnabled = true; isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ isDebuggable = false; signingConfig = signingConfigs.getByName("release")
}
-
create("pro") {
- isMinifyEnabled = true
- isShrinkResources = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- applicationIdSuffix = ".pro"
- isDebuggable = false
- if (releaseSigningConfigValid) {
- signingConfig = signingConfigs.getByName("release")
- }
+ isMinifyEnabled = true; isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ applicationIdSuffix = ".pro"; isDebuggable = false
+ if (releaseSigningConfigValid) signingConfig = signingConfigs.getByName("release")
versionNameSuffix = "-Pro"
}
-
create("proPlus") {
- isMinifyEnabled = true
- isShrinkResources = true
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- applicationIdSuffix = ".proplus"
- isDebuggable = false
- if (releaseSigningConfigValid) {
- signingConfig = signingConfigs.getByName("release")
- }
+ isMinifyEnabled = true; isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ applicationIdSuffix = ".proplus"; isDebuggable = false
+ if (releaseSigningConfigValid) signingConfig = signingConfigs.getByName("release")
versionNameSuffix = "-Pro+"
- // Custom app name via gradle property
val customAppName = project.findProperty("customAppName")?.toString() ?: "FadCam Pro+"
resValue("string", "app_name", customAppName)
}
}
flavorDimensions += "pro"
-
productFlavors {
- create("notesPro") {
- dimension = "pro"
- applicationIdSuffix = ".notes"
- resValue("string", "app_name", "Notes")
- }
- create("calcPro") {
- dimension = "pro"
- applicationIdSuffix = ".calc"
- resValue("string", "app_name", "Calculator")
- }
- create("weatherPro") {
- dimension = "pro"
- applicationIdSuffix = ".weather"
- resValue("string", "app_name", "Weather")
- }
- create("default") {
- dimension = "pro"
- // Default for proPlus builds
- }
+ create("notesPro") { dimension = "pro"; applicationIdSuffix = ".notes"; resValue("string", "app_name", "Notes") }
+ create("calcPro") { dimension = "pro"; applicationIdSuffix = ".calc"; resValue("string", "app_name", "Calculator") }
+ create("weatherPro") { dimension = "pro"; applicationIdSuffix = ".weather"; resValue("string", "app_name", "Weather") }
+ create("default") { dimension = "pro" }
}
-// ./gradlew assembleNotesProRelease - Notes Pro variant
-// ./gradlew assembleCalcProRelease - Calculator Pro variant
-// ./gradlew assembleWeatherProRelease - Weather Pro variant
-// ./gradlew assembleDefaultProPlusRelease -PcustomAppName="Custom Name" - Pro+ custom build (standalone)
-
- // Variant filter: only build specific variants (modern API — the old
- // variantFilter{} is deprecated since AGP 8.x).
androidComponents {
beforeVariants { variant ->
val isPreBuiltFlavor = variant.name.contains("notesPro") || variant.name.contains("calcPro") || variant.name.contains("weatherPro")
val isDefaultFlavor = variant.name.contains("default")
-
- if (isPreBuiltFlavor) {
- // Pre-built flavors: only 'release' build type
- if (!variant.name.endsWith("Release")) {
- variant.enable = false
- }
- } else if (isDefaultFlavor) {
- // Default flavor: allow 'debug', 'release', and 'proPlus' build types
- if (variant.name.endsWith("Pro") && !variant.name.endsWith("ProPlus")) {
- variant.enable = false
- }
- }
+ if (isPreBuiltFlavor) { if (!variant.name.endsWith("Release")) variant.enable = false }
+ else if (isDefaultFlavor) { if (variant.name.endsWith("Pro") && !variant.name.endsWith("ProPlus")) variant.enable = false }
}
}
- // Dynamic APK output names: FadCam_The controller deliberately has no Activity or platform alarm dependency. A - * monotonically increasing session generation prevents a removed or delayed callback - * from affecting a later recording.
+ *Production recording is intentionally not stopped by a wall-clock + * duration. A recording may continue until the operator stops it or the + * selected storage cannot accept more data. This compatibility boundary is + * retained so older callers and settings do not break.
*/ public final class RecordingDurationLimitController { public interface Scheduler { long elapsedRealtime(); - void postDelayed(Runnable runnable, long delayMs); - void removeCallbacks(Runnable runnable); } @@ -20,26 +19,20 @@ public interface LimitProvider { long getLimitMs(); } - /** Fires once per second while 1..10 seconds remain before the limit. */ + /** Compatibility listener; no countdown ticks are emitted in unlimited mode. */ public interface RemainingTickListener { void onRemainingSecond(int remainingSeconds); } - private static final long REMAINING_TICK_WINDOW_MS = 10_000L; - + @SuppressWarnings("unused") private final Scheduler scheduler; + @SuppressWarnings("unused") private final LimitProvider limitProvider; + @SuppressWarnings("unused") private final Runnable onLimitReached; - + @SuppressWarnings("unused") private RemainingTickListener remainingTickListener; - private Runnable scheduledTick; private boolean sessionActive; - private boolean counting; - private long sessionGeneration; - private long scheduleGeneration; - private long accumulatedRecordingMs; - private long countingStartedAtMs; - private Runnable scheduledCallback; public RecordingDurationLimitController( Scheduler scheduler, @@ -50,203 +43,36 @@ public RecordingDurationLimitController( this.onLimitReached = onLimitReached; } - /** Starts a new recording session and invalidates every callback from an older one. */ + /** Starts a recording session without installing any duration timeout. */ public synchronized long startSession() { - invalidateScheduleLocked(); - sessionGeneration++; sessionActive = true; - counting = true; - accumulatedRecordingMs = 0L; - countingStartedAtMs = scheduler.elapsedRealtime(); - return scheduleLocked(); + return 0L; } - /** Sets the listener that observes the final seconds of the countdown. */ + /** Compatibility hook; intentionally never schedules a countdown. */ public synchronized void setRemainingTickListener(RemainingTickListener listener) { this.remainingTickListener = listener; } - /** Stops the current session and invalidates its pending callback. */ + /** Stops this controller session; it does not stop the recording. */ public synchronized boolean stopSession() { - boolean hadSession = sessionActive || scheduledCallback != null; + boolean hadSession = sessionActive; sessionActive = false; - counting = false; - accumulatedRecordingMs = 0L; - sessionGeneration++; - invalidateScheduleLocked(); return hadSession; } - /** Pauses the countdown so the limit follows recorded media time, not paused time. */ + /** Pausing never consumes a duration budget. */ public synchronized boolean pauseSession() { - if (!sessionActive || !counting) { - return false; - } - accumulatedRecordingMs = getElapsedRecordingMsLocked(); - counting = false; - invalidateScheduleLocked(); - return true; + return sessionActive; } - /** Resumes a paused session without resetting its accumulated recording time. */ + /** Resuming never reinstalls a duration timeout. */ public synchronized boolean resumeSession() { - if (!sessionActive || counting) { - return false; - } - counting = true; - countingStartedAtMs = scheduler.elapsedRealtime(); - scheduleLocked(); - return true; - } - - /** Re-evaluates the current session after the user changes the configured limit. */ - public boolean onLimitChanged() { - boolean notifyLimitReached = false; - synchronized (this) { - if (!sessionActive) { - return false; - } - - invalidateScheduleLocked(); - long limitMs = Math.max(0L, limitProvider.getLimitMs()); - if (limitMs != 0L) { - long elapsedMs = getElapsedRecordingMsLocked(); - if (elapsedMs >= limitMs) { - completeSessionLocked(); - notifyLimitReached = true; - } else if (counting) { - scheduleLocked(); - } - } - } - - if (notifyLimitReached) { - onLimitReached.run(); - } - return true; - } - - private long scheduleLocked() { - invalidateScheduleLocked(); - long limitMs = Math.max(0L, limitProvider.getLimitMs()); - if (!sessionActive || !counting || limitMs == 0L) { - return limitMs; - } - - long elapsedMs = getElapsedRecordingMsLocked(); - long remainingMs = elapsedMs >= limitMs ? 0L : limitMs - elapsedMs; - long expectedSession = sessionGeneration; - long expectedSchedule = scheduleGeneration; - Runnable callback = () -> handleTimeout(expectedSession, expectedSchedule); - scheduledCallback = callback; - scheduler.postDelayed(callback, remainingMs); - if (remainingMs > 0L && remainingMs <= REMAINING_TICK_WINDOW_MS) { - scheduleRemainingTicksLocked(remainingMs, expectedSession, expectedSchedule); - } - return limitMs; - } - - /** - * Schedules per-second ticks for the final 10 seconds. The first tick fires - * after {@code remainingMs % 1000} so the tick coincides with a whole-second - * boundary, then every second until the limit callback takes over. - */ - private void scheduleRemainingTicksLocked( - long remainingMs, long expectedSession, long expectedSchedule) { - if (remainingTickListener == null) { - return; - } - long firstDelayMs = remainingMs % 1000L; - int[] remainingSeconds = {(int) ((remainingMs + 999L) / 1000L)}; - Runnable tick = new Runnable() { - @Override - public void run() { - boolean reschedule = false; - synchronized (RecordingDurationLimitController.this) { - if (!sessionActive - || !counting - || expectedSession != sessionGeneration - || expectedSchedule != scheduleGeneration) { - return; - } - scheduledTick = null; - if (remainingTickListener != null) { - remainingTickListener.onRemainingSecond(remainingSeconds[0]); - } - if (remainingSeconds[0] > 1) { - remainingSeconds[0]--; - reschedule = true; - scheduledTick = this; - } - } - if (reschedule) { - scheduler.postDelayed(this, 1000L); - } - } - }; - scheduledTick = tick; - scheduler.postDelayed(tick, firstDelayMs); - } - - private void handleTimeout(long expectedSession, long expectedSchedule) { - boolean notifyLimitReached = false; - synchronized (this) { - if (!sessionActive - || !counting - || expectedSession != sessionGeneration - || expectedSchedule != scheduleGeneration) { - return; - } - - scheduledCallback = null; - long limitMs = Math.max(0L, limitProvider.getLimitMs()); - if (limitMs == 0L) { - return; - } - - long elapsedMs = getElapsedRecordingMsLocked(); - if (elapsedMs < limitMs) { - scheduleLocked(); - return; - } - - completeSessionLocked(); - notifyLimitReached = true; - } - - if (notifyLimitReached) { - onLimitReached.run(); - } - } - - private long getElapsedRecordingMsLocked() { - if (!counting) { - return accumulatedRecordingMs; - } - long nowMs = scheduler.elapsedRealtime(); - long deltaMs = nowMs >= countingStartedAtMs ? nowMs - countingStartedAtMs : 0L; - if (Long.MAX_VALUE - accumulatedRecordingMs < deltaMs) { - return Long.MAX_VALUE; - } - return accumulatedRecordingMs + deltaMs; - } - - private void completeSessionLocked() { - sessionActive = false; - counting = false; - sessionGeneration++; - scheduleGeneration++; + return sessionActive; } - private void invalidateScheduleLocked() { - scheduleGeneration++; - if (scheduledCallback != null) { - scheduler.removeCallbacks(scheduledCallback); - scheduledCallback = null; - } - if (scheduledTick != null) { - scheduler.removeCallbacks(scheduledTick); - scheduledTick = null; - } + /** Duration preferences no longer control whether a production recording stops. */ + public synchronized boolean onLimitChanged() { + return sessionActive; } } diff --git a/app/src/main/java/com/fadcam/SplashActivity.java b/app/src/main/java/com/fadcam/SplashActivity.java index 5a1a6b1c9..089722950 100644 --- a/app/src/main/java/com/fadcam/SplashActivity.java +++ b/app/src/main/java/com/fadcam/SplashActivity.java @@ -11,25 +11,30 @@ import com.fadcam.utils.RuntimeCompat; /** - * Simple splash screen activity showing the FadSecLab flag centered. - * Uses a short delay, then routes to the appropriate main activity: - * - Wear OS: {@link WatchMainActivity} (minimal watch UI) - * - Phone/tablet: {@link MainActivity} + * Splash router. + * Debug builds enter the new professional Studio dashboard first so the UI can + * be verified independently. Release builds keep the existing MainActivity flow. */ public class SplashActivity extends Activity { - private static final long SPLASH_DELAY_MS = 800; // short, just to show logo + private static final long SPLASH_DELAY_MS = 800; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_splash); ImageView iv = findViewById(R.id.splash_image); + new Handler(Looper.getMainLooper()).postDelayed(() -> { - Class> destination = RuntimeCompat.shouldUseWatchUi(SplashActivity.this) - ? WatchMainActivity.class - : MainActivity.class; + final Class> destination; + if (BuildConfig.DEBUG && !RuntimeCompat.shouldUseWatchUi(SplashActivity.this)) { + destination = StudioActivity.class; + } else { + destination = RuntimeCompat.shouldUseWatchUi(SplashActivity.this) + ? WatchMainActivity.class + : MainActivity.class; + } + startActivity(new Intent(SplashActivity.this, destination)); - // Apply fade transition instead of default activity animation overridePendingTransition(R.anim.fade_in, R.anim.fade_out); finish(); }, SPLASH_DELAY_MS); diff --git a/app/src/main/java/com/fadcam/StudioActivity.java b/app/src/main/java/com/fadcam/StudioActivity.java new file mode 100644 index 000000000..cd86fc592 --- /dev/null +++ b/app/src/main/java/com/fadcam/StudioActivity.java @@ -0,0 +1,468 @@ +package com.fadcam; + +import android.Manifest; +import android.app.AlertDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.MediaStore; +import android.text.InputType; +import android.text.method.PasswordTransformationMethod; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.camera.core.CameraSelector; +import androidx.camera.view.CameraController; +import androidx.camera.view.LifecycleCameraController; +import androidx.camera.view.PreviewView; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.fadcam.streaming.RemoteStreamManager; +import com.fadcam.streaming.RemoteStreamService; +import com.fadcam.studio.StudioGraphicsRecorderBridge; +import com.fadcam.studio.StudioRtmpConfig; +import com.fadcam.studio.StudioRtmpSecretStore; +import com.fadcam.studio.StudioRtmpStreamService; + +import java.util.ArrayList; +import java.util.List; + +/** Professional FadCam production-room dashboard. */ +public class StudioActivity extends AppCompatActivity { + private static final int PERMISSION_REQUEST = 7101; + + private PreviewView previewView; + private ImageView programSnapshot; + private TextView liveStatus; + private TextView recordingStatus; + private TextView streamStatus; + private TextView activeCameraLabel; + private TextView transitionLabel; + private TextView graphicsOverlay; + private TextView programGraphics; + private TextView audioStatus; + private TextView replayStatus; + private TextView rtmpStatus; + + private LifecycleCameraController cameraController; + private final Handler handler = new Handler(Looper.getMainLooper()); + private boolean frontCamera; + private boolean programIsFront; + private boolean programFrozen; + private boolean recording; + private boolean streaming; + private String graphicsText = "FADCAM • LIVE"; + + private final Runnable snapshotRunnable = new Runnable() { + @Override public void run() { + if (!programFrozen && previewView != null && programSnapshot != null) { + Bitmap bitmap = previewView.getBitmap(); + if (bitmap != null && !bitmap.isRecycled()) programSnapshot.setImageBitmap(bitmap); + } + handler.postDelayed(this, 250L); + } + }; + + private final Runnable stateRunnable = new Runnable() { + @Override public void run() { + refreshEngineState(); + handler.postDelayed(this, 1000L); + } + }; + + @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_studio); + + previewView = findViewById(R.id.studio_preview); + programSnapshot = findViewById(R.id.studio_program_snapshot); + liveStatus = findViewById(R.id.studio_live_status); + recordingStatus = findViewById(R.id.studio_record_status); + streamStatus = findViewById(R.id.studio_stream_status); + activeCameraLabel = findViewById(R.id.studio_active_camera); + transitionLabel = findViewById(R.id.studio_transition_label); + graphicsOverlay = findViewById(R.id.studio_graphics_overlay); + programGraphics = findViewById(R.id.studio_program_graphics); + audioStatus = findViewById(R.id.studio_audio_status); + replayStatus = findViewById(R.id.studio_replay_status); + rtmpStatus = findViewById(R.id.studio_rtmp_status); + + graphicsText = getSharedPreferences("FadCamStudioGraphics", MODE_PRIVATE) + .getString("graphic", graphicsText); + if (graphicsText == null || graphicsText.trim().isEmpty()) graphicsText = "FADCAM • LIVE"; + + configureSwitcher(); + configureProductionControls(); + configureGraphics(); + configureAudioRouting(); + configureReplay(); + configureRemoteControl(); + configureRtmp(); + + if (hasCapturePermissions()) startCamera(); + else ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO}, PERMISSION_REQUEST); + + refreshEngineState(); + handler.post(stateRunnable); + } + + private boolean hasCapturePermissions() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + } + + private void startCamera() { + cameraController = new LifecycleCameraController(this); + cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE); + cameraController.setCameraSelector(frontCamera ? CameraSelector.DEFAULT_FRONT_CAMERA : CameraSelector.DEFAULT_BACK_CAMERA); + cameraController.bindToLifecycle(this); + previewView.setController(cameraController); + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + updateCameraLabels(); + handler.removeCallbacks(snapshotRunnable); + handler.post(snapshotRunnable); + } + + private void configureSwitcher() { + int[] ids = {R.id.studio_cam1, R.id.studio_cam2, R.id.studio_cam3, R.id.studio_cam4, + R.id.studio_cam5, R.id.studio_cam6, R.id.studio_cam7, R.id.studio_cam8}; + for (int i = 0; i < ids.length; i++) { + final int cameraNumber = i + 1; + View tile = findViewById(ids[i]); + if (tile != null) tile.setOnClickListener(v -> selectCamera(cameraNumber)); + } + findViewById(R.id.studio_cut).setOnClickListener(v -> takeProgramLive("CUT")); + findViewById(R.id.studio_fade).setOnClickListener(v -> takeProgramLive("FADE")); + findViewById(R.id.studio_mix).setOnClickListener(v -> takeProgramLive("MIX")); + } + + private void selectCamera(int cameraNumber) { + if (cameraNumber != 1 && cameraNumber != 2) { + Toast.makeText(this, "CAM " + cameraNumber + " is not connected", Toast.LENGTH_SHORT).show(); + return; + } + frontCamera = cameraNumber == 2; + if (cameraController == null) return; + try { + cameraController.setCameraSelector(frontCamera ? CameraSelector.DEFAULT_FRONT_CAMERA : CameraSelector.DEFAULT_BACK_CAMERA); + updateCameraLabels(); + transitionLabel.setText("CAM " + cameraNumber + " PREVIEW"); + } catch (IllegalStateException e) { + Toast.makeText(this, "Selected camera is unavailable", Toast.LENGTH_SHORT).show(); + } + } + + private void takeProgramLive(String transition) { + if (programSnapshot == null || previewView == null) return; + Bitmap bitmap = previewView.getBitmap(); + if (bitmap == null) { + Toast.makeText(this, "Program frame is not ready", Toast.LENGTH_SHORT).show(); + return; + } + programFrozen = true; + if ("FADE".equals(transition)) { + programSnapshot.animate().alpha(0f).setDuration(160).withEndAction(() -> { + programSnapshot.setImageBitmap(bitmap); + programSnapshot.animate().alpha(1f).setDuration(220).start(); + }).start(); + } else if ("MIX".equals(transition)) { + programSnapshot.setAlpha(0.35f); + programSnapshot.setImageBitmap(bitmap); + programSnapshot.animate().alpha(1f).setDuration(350).start(); + } else { + programSnapshot.setAlpha(1f); + programSnapshot.setImageBitmap(bitmap); + } + programIsFront = frontCamera; + transitionLabel.setText(transition + " • PROGRAM CAM " + (programIsFront ? "2" : "1")); + graphicsOverlay.bringToFront(); + programGraphics.bringToFront(); + } + + private void configureProductionControls() { + findViewById(R.id.studio_record_button).setOnClickListener(v -> toggleRecording()); + findViewById(R.id.studio_stream_button).setOnClickListener(v -> toggleRtmpOutput()); + findViewById(R.id.studio_back_to_camera).setOnClickListener(v -> { + startActivity(new Intent(this, MainActivity.class)); + finish(); + }); + } + + /** Starts/stops FadCam's real RecordingService. The Studio graphic is enabled in the GL encoder path. */ + private void toggleRecording() { + if (!hasCapturePermissions()) { + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO}, PERMISSION_REQUEST); + return; + } + Intent intent = new Intent(this, com.fadcam.services.RecordingService.class); + intent.setAction(recording ? Constants.INTENT_ACTION_STOP_RECORDING : Constants.INTENT_ACTION_START_RECORDING); + if (!recording) StudioGraphicsRecorderBridge.enable(this, graphicsText); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) startForegroundService(intent); + else startService(intent); + recording = !recording; + updateRecordStatus(); + } + + private void configureGraphics() { + graphicsOverlay.setText(graphicsText); + programGraphics.setText(graphicsText); + findViewById(R.id.studio_graphics_button).setOnClickListener(v -> editGraphics()); + } + + private void editGraphics() { + final EditText input = new EditText(this); + input.setSingleLine(false); + input.setText(graphicsText); + input.setSelectAllOnFocus(true); + new AlertDialog.Builder(this) + .setTitle("Program Graphic") + .setMessage("This graphic is also sent into FadCam's GL recording encoder while RECORD is active.") + .setView(input) + .setPositiveButton("APPLY", (dialog, which) -> { + graphicsText = input.getText().toString().trim(); + if (graphicsText.isEmpty()) graphicsText = "FADCAM • LIVE"; + graphicsOverlay.setText(graphicsText); + programGraphics.setText(graphicsText); + if (recording) StudioGraphicsRecorderBridge.update(this, graphicsText); + }) + .setNegativeButton("CANCEL", null) + .show(); + } + + private void configureAudioRouting() { + findViewById(R.id.studio_audio_button).setOnClickListener(v -> showAudioDevices()); + } + + private void showAudioDevices() { + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) return; + ListAll three destinations are live: phone local storage, SD/memory card, + * and USB external storage. SD/USB selections use Android's Storage Access + * Framework and persist the user's write permission, so the recording + * pipeline can write its normal FadCam folder tree to the selected volume.
*/ public class StorageSettingsFragment extends Fragment { private static final String TAG = "StorageSettingsFragment"; + private static final String RESULT_KEY = "picker_result_storage"; private SharedPreferencesManager prefs; private TextView valueStorageMode; + private String pendingDestination = StorageDestinationManager.DESTINATION_SD; - private ActivityResultLauncher