diff --git a/app/build.gradle b/app/build.gradle
index de58b67..9a46c28 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -61,6 +61,12 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
+
+ allprojects {
+ repositories {
+ maven { url 'https://jitpack.io' }
+ }
+ }
}
dependencies {
@@ -71,6 +77,7 @@ dependencies {
implementation 'com.google.android.exoplayer:exoplayer:2.13.3'
implementation 'io.sentry:sentry-android:4.3.0'
implementation 'androidx.preference:preference:1.1.1'
+ implementation 'com.github.pedroSG94.rtmp-rtsp-stream-client-java:rtplibrary:2.0.2'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7df7ca4..23ea73b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,7 +4,11 @@
package="com.fpvout.digiview">
+
+
+
+
+ android:theme="@style/Theme.Digiview">
+ android:theme="@style/Theme.AppCompat.Dialog" />
+ android:label="@string/title_activity_settings" />
+ android:configChanges="orientation|keyboardHidden"
+ android:launchMode="singleTask"
+ android:screenOrientation="sensorLandscape">
@@ -38,14 +41,26 @@
-
-
-
-
+
+
+
+
+
+
{
+ Toast.makeText(getApplicationContext(), getString(R.string.rtmp_connection_failed), Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ @Override
+ public void onNewBitrateRtmp(long bitrate) {
+ runOnUiThread(() -> {
+ bitrateTextview.setVisibility(View.VISIBLE);
+ bitrateTextview.setText(String.format("%s kbps", bitrate / 1024));
+ });
+ }
+
+ @Override
+ public void onDisconnectRtmp() {
+ updateLiveButtonIcon();
+ runOnUiThread(() -> {
+ bitrateTextview.setText("");
+ bitrateTextview.setVisibility(View.GONE);
+ });
+ }
+
+ @Override
+ public void onAuthErrorRtmp() {
+ updateLiveButtonIcon();
+ runOnUiThread(() -> {
+ Toast.makeText(getApplicationContext(), getString(R.string.rtmp_auth_error), Toast.LENGTH_SHORT).show();
+ });
+ }
+
+ @Override
+ public void onAuthSuccessRtmp() {
+ updateLiveButtonIcon();
+ }
+ };
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -95,11 +169,37 @@ protected void onCreate(Bundle savedInstanceState) {
fpvView = findViewById(R.id.fpvView);
settingsButton = findViewById(R.id.settingsButton);
- settingsButton.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
- Intent intent = new Intent(v.getContext(), SettingsActivity.class);
- v.getContext().startActivity(intent);
+ settingsButton.setOnClickListener(v -> {
+ Intent intent = new Intent(v.getContext(), SettingsActivity.class);
+ v.getContext().startActivity(intent);
+ });
+
+ bitrateTextview = findViewById(R.id.bitrateText);
+ StreamingService.init(this, connectChecker);
+
+ muteButton = findViewById(R.id.muteButton);
+ muteButton.setOnClickListener(v -> {
+ StreamingService.toggleMute();
+ updateLiveButtonIcon();
+ });
+
+ liveButton = findViewById(R.id.liveButton);
+ liveButton.setOnClickListener(v -> {
+ if (!StreamingService.isStreaming()) {
+ if (sharedPreferences.getString("StreamRtmpUrl", "").isEmpty() || sharedPreferences.getString("StreamRtmpKey", "").isEmpty()) {
+ Toast.makeText(this, getString(R.string.rtmp_settings_empty), Toast.LENGTH_LONG).show();
+ Intent intent = new Intent(v.getContext(), SettingsActivity.class);
+ v.getContext().startActivity(intent);
+ } else {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
+ startActivityForResult(StreamingService.sendIntent(), MEDIA_PROJECTION_PERMISSION);
+ } else {
+ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO}, RECORD_AUDIO_PERMISSION);
+ }
+ }
+ } else {
+ stopService(new Intent(this, StreamingService.class));
+ this.updateLiveButtonIcon();
}
});
@@ -124,11 +224,94 @@ public void onClick(View v) {
}
}
+ private void toggleFullOverlay() {
+ if (overlayView.getAlpha() > 0.0f) return;
+
+ toggleView(settingsButton, hideButtonsRunnable);
+ toggleView(liveButton, hideButtonsRunnable);
+ if (StreamingService.isStreaming()) {
+ toggleView(bitrateTextview, hideButtonsRunnable);
+ toggleView(muteButton, hideButtonsRunnable);
+ }
+ }
+
+ private void hideFullOverlay() {
+ toggleView(watermarkView, sharedPreferences.getBoolean(ShowWatermark, true), 0.3f);
+
+ toggleView(settingsButton, false);
+ toggleView(liveButton, false);
+ toggleView(muteButton, false);
+ toggleView(bitrateTextview, false);
+ toggleView(overlayView, false);
+ }
+
+ private void showFullOverlay() {
+ toggleView(watermarkView, false);
+
+ toggleView(settingsButton, true);
+ toggleView(liveButton, true);
+ if (StreamingService.isStreaming()) {
+ toggleView(muteButton, true);
+ toggleView(bitrateTextview, true);
+ }
+ }
+
+ private void toggleView(View view, @Nullable Runnable runnable) {
+ toggleView(view, view.getAlpha() == 0.0f, 1.0f, runnable);
+ }
+
+ private void toggleView(FloatingActionButton view, @Nullable Runnable runnable) {
+ toggleView(view, view.getVisibility() != View.VISIBLE, runnable);
+ }
+
+ private void toggleView(View view, boolean visible) {
+ toggleView(view, visible, 1.0f, null);
+ }
+
+ private void toggleView(FloatingActionButton view, boolean visible) {
+ toggleView(view, visible, null);
+ }
+
+ private void toggleView(View view, boolean visible, float visibleAlpha) {
+ toggleView(view, visible, visibleAlpha, null);
+ }
+
+ private void toggleView(View view, boolean visible, float visibleAlpha, @Nullable Runnable runnable) {
+ if (!visible) {
+ view.removeCallbacks(runnable);
+ view.animate().cancel();
+ view.animate()
+ .alpha(0)
+ .setDuration(shortAnimationDuration)
+ .setListener(null);
+ } else {
+ view.removeCallbacks(runnable);
+ view.animate().cancel();
+ view.animate()
+ .alpha(visibleAlpha)
+ .setDuration(shortAnimationDuration);
+ view.postDelayed(runnable, 3000);
+ }
+ }
+
+ private void toggleView(FloatingActionButton view, boolean visible, @Nullable Runnable runnable) {
+ if (view.getHandler() != null) {
+ view.getHandler().removeCallbacksAndMessages(null);
+ }
+
+ if (!visible) {
+ view.hide();
+ } else {
+ view.show();
+ view.postDelayed(runnable, 3000);
+ }
+ }
+
private void setupGestureDetectors() {
gestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
- toggleSettingsButton();
+ toggleFullOverlay();
return super.onSingleTapConfirmed(e);
}
@@ -159,81 +342,6 @@ public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
- private void updateWatermark() {
- if (overlayView.getVisibility() == View.VISIBLE) {
- watermarkView.setAlpha(0);
- return;
- }
-
- if (sharedPreferences.getBoolean(ShowWatermark, true)) {
- watermarkView.setAlpha(0.3F);
- } else {
- watermarkView.setAlpha(0F);
- }
- }
-
- private void updateVideoZoom() {
- if (sharedPreferences.getBoolean(VideoZoomedIn, true)) {
- mVideoReader.zoomIn();
- } else {
- mVideoReader.zoomOut();
- }
- }
-
- private void cancelButtonAnimation() {
- Handler handler = settingsButton.getHandler();
- if (handler != null) {
- settingsButton.getHandler().removeCallbacksAndMessages(null);
- }
- }
-
- private void showSettingsButton() {
- cancelButtonAnimation();
-
- if (overlayView.getVisibility() == View.VISIBLE) {
- buttonAlpha = 1;
- settingsButton.setAlpha(1);
- }
- }
-
- private void toggleSettingsButton() {
- if (buttonAlpha == 1 && overlayView.getVisibility() == View.VISIBLE) return;
-
- // cancel any pending delayed animations first
- cancelButtonAnimation();
-
- if (buttonAlpha == 1) {
- buttonAlpha = 0;
- } else {
- buttonAlpha = 1;
- }
-
- settingsButton.animate()
- .alpha(buttonAlpha)
- .setDuration(shortAnimationDuration)
- .setListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(Animator animation) {
- autoHideSettingsButton();
- }
- });
- }
-
- private void autoHideSettingsButton() {
- if (overlayView.getVisibility() == View.VISIBLE) return;
- if (buttonAlpha == 0) return;
-
- settingsButton.postDelayed(new Runnable() {
- @Override
- public void run() {
- buttonAlpha = 0;
- settingsButton.animate()
- .alpha(0)
- .setDuration(shortAnimationDuration);
- }
- }, 3000);
- }
-
@Override
public void usbDeviceApproved(UsbDevice device) {
Log.i(TAG, "USB - usbDevice approved");
@@ -276,10 +384,7 @@ private void connect() {
usbConnected = true;
mUsbMaskConnection.setUsbDevice(usbManager.openDevice(usbDevice), usbDevice);
mVideoReader.setUsbMaskConnection(mUsbMaskConnection);
- overlayView.hide();
mVideoReader.start();
- updateWatermark();
- autoHideSettingsButton();
showOverlay(R.string.waiting_for_video, OverlayStatus.Connected);
}
@@ -308,11 +413,6 @@ public void onResume() {
showOverlay(R.string.waiting_for_usb_device, OverlayStatus.Connected);
}
}
-
- settingsButton.setAlpha(1);
- autoHideSettingsButton();
- updateWatermark();
- updateVideoZoom();
}
private boolean onVideoReaderEvent(VideoReaderExoplayer.VideoReaderEventMessageCode m) {
@@ -321,22 +421,15 @@ private boolean onVideoReaderEvent(VideoReaderExoplayer.VideoReaderEventMessageC
showOverlay(R.string.waiting_for_video, OverlayStatus.Connected);
} else if (VideoReaderExoplayer.VideoReaderEventMessageCode.VIDEO_PLAYING.equals(m)) {
Log.d(TAG, "event: VIDEO_PLAYING");
- hideOverlay();
+ hideFullOverlay();
}
return false; // false to continue listening
}
private void showOverlay(int textId, OverlayStatus connected) {
+ toggleView(overlayView, true);
+ showFullOverlay();
overlayView.show(textId, connected);
- updateWatermark();
- showSettingsButton();
- }
-
- private void hideOverlay() {
- overlayView.hide();
- updateWatermark();
- showSettingsButton();
- autoHideSettingsButton();
}
@Override
@@ -376,7 +469,7 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
boolean dataCollectionAccepted = preferences.getBoolean("dataCollectionAccepted", false);
- if (requestCode == 1) { // Data Collection agreement Activity
+ if (requestCode == DATA_COLLECTION_AGREEMENT) { // Data Collection agreement Activity
if (resultCode == RESULT_OK && dataCollectionAccepted) {
SentryAndroid.init(this, options -> options.setBeforeSend((event, hint) -> {
if (SentryLevel.DEBUG.equals(event.getLevel()))
@@ -385,9 +478,23 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
return event;
}));
}
+ }
+
+ if (data != null && (requestCode == MEDIA_PROJECTION_PERMISSION && resultCode == Activity.RESULT_OK)) {
+ StreamingService.setMediaProjectionData(resultCode, data);
+ Intent intent = new Intent(this, StreamingService.class);
+ startService(intent);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == RECORD_AUDIO_PERMISSION && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
+ startActivityForResult(StreamingService.sendIntent(), MEDIA_PROJECTION_PERMISSION);
}
- } //onActivityResult
+ }
private void checkDataCollectionAgreement() {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
@@ -395,7 +502,7 @@ private void checkDataCollectionAgreement() {
boolean dataCollectionReplied = preferences.getBoolean("dataCollectionReplied", false);
if (!dataCollectionReplied) {
Intent intent = new Intent(this, DataCollectionAgreementPopupActivity.class);
- startActivityForResult(intent, 1);
+ startActivityForResult(intent, DATA_COLLECTION_AGREEMENT);
} else if (dataCollectionAccepted) {
SentryAndroid.init(this, options -> options.setBeforeSend((event, hint) -> {
if (SentryLevel.DEBUG.equals(event.getLevel()))
@@ -404,7 +511,23 @@ private void checkDataCollectionAgreement() {
return event;
}));
}
-
}
+ private void updateLiveButtonIcon() {
+ runOnUiThread(() -> {
+ if (StreamingService.isStreaming()) {
+ if (StreamingService.isMuted()) {
+ muteButton.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_microphone_slash_solid, this.getTheme()));
+ } else {
+ muteButton.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_microphone_solid, this.getTheme()));
+ }
+
+ toggleView(muteButton, true, overlayView.getAlpha() > 0.0f ? null : hideButtonsRunnable);
+ liveButton.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.exo_icon_stop, this.getTheme()));
+ } else {
+ toggleView(muteButton, false);
+ liveButton.setImageDrawable(ResourcesCompat.getDrawable(getResources(), R.drawable.ic_live_icon, this.getTheme()));
+ }
+ });
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/fpvout/digiview/OverlayView.java b/app/src/main/java/com/fpvout/digiview/OverlayView.java
index b1327c8..8bbc442 100644
--- a/app/src/main/java/com/fpvout/digiview/OverlayView.java
+++ b/app/src/main/java/com/fpvout/digiview/OverlayView.java
@@ -2,7 +2,6 @@
import android.content.Context;
import android.util.AttributeSet;
-import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
@@ -22,18 +21,11 @@ public OverlayView(Context context, AttributeSet attrs) {
imageView = findViewById(R.id.backdrop_image);
}
- public void hide(){
- setVisibility(View.GONE);
- }
-
public void show(int textResourceId, OverlayStatus status){
showInfo(getContext().getString(textResourceId), status);
}
private void showInfo(String text, OverlayStatus status){
-
- setVisibility(View.VISIBLE);
-
textView.setText(text);
int image = R.drawable.ic_goggles_white;
diff --git a/app/src/main/java/com/fpvout/digiview/SettingsActivity.java b/app/src/main/java/com/fpvout/digiview/SettingsActivity.java
index 1b552a8..6c70174 100644
--- a/app/src/main/java/com/fpvout/digiview/SettingsActivity.java
+++ b/app/src/main/java/com/fpvout/digiview/SettingsActivity.java
@@ -1,13 +1,20 @@
package com.fpvout.digiview;
+import android.os.Build;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.preference.ListPreference;
import androidx.preference.PreferenceFragmentCompat;
+import com.fpvout.digiview.streaming.StreamAudioSource;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
public class SettingsActivity extends AppCompatActivity {
@Override
@@ -41,6 +48,22 @@ public static class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.root_preferences, rootKey);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ListPreference audioSourcePreference = findPreference("StreamAudioSource");
+
+ ArrayList entries = new ArrayList<>(Arrays.asList(audioSourcePreference.getEntries()));
+ ArrayList entryValues = new ArrayList<>(Arrays.asList(audioSourcePreference.getEntryValues()));
+
+ entries.add(getString(R.string.stream_audio_source_performance));
+ entryValues.add(StreamAudioSource.PERFORMANCE);
+
+ entries.add(getString(R.string.stream_audio_source_internal));
+ entryValues.add(StreamAudioSource.INTERNAL);
+
+ audioSourcePreference.setEntries(entries.toArray(new CharSequence[0]));
+ audioSourcePreference.setEntryValues(entryValues.toArray(new CharSequence[0]));
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/CustomDisplayBase.java b/app/src/main/java/com/fpvout/digiview/streaming/CustomDisplayBase.java
new file mode 100644
index 0000000..91fcae5
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/CustomDisplayBase.java
@@ -0,0 +1,642 @@
+package com.fpvout.digiview.streaming;
+
+import android.content.Context;
+import android.content.Intent;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.VirtualDisplay;
+import android.media.AudioAttributes;
+import android.media.AudioPlaybackCaptureConfiguration;
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.media.MediaRecorder;
+import android.media.projection.MediaProjection;
+import android.media.projection.MediaProjectionManager;
+import android.os.Build;
+import android.view.Surface;
+import android.view.SurfaceView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import com.pedro.encoder.Frame;
+import com.pedro.encoder.audio.AudioEncoder;
+import com.pedro.encoder.audio.GetAacData;
+import com.pedro.encoder.input.audio.CustomAudioEffect;
+import com.pedro.encoder.input.audio.GetMicrophoneData;
+import com.pedro.encoder.input.audio.MicrophoneManager;
+import com.pedro.encoder.input.audio.MicrophoneManagerManual;
+import com.pedro.encoder.input.audio.MicrophoneMode;
+import com.pedro.encoder.utils.CodecUtil;
+import com.pedro.encoder.video.FormatVideoEncoder;
+import com.pedro.encoder.video.GetVideoData;
+import com.pedro.encoder.video.VideoEncoder;
+import com.pedro.rtplibrary.util.FpsListener;
+import com.pedro.rtplibrary.util.RecordController;
+import com.pedro.rtplibrary.view.GlInterface;
+import com.pedro.rtplibrary.view.OffScreenGlThread;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+import static android.content.Context.MEDIA_PROJECTION_SERVICE;
+
+@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+public abstract class CustomDisplayBase implements GetAacData, GetVideoData, GetMicrophoneData {
+
+ private OffScreenGlThread glInterface;
+ protected Context context;
+ private MediaProjection mediaProjection;
+ private MediaProjectionManager mediaProjectionManager;
+ protected VideoEncoder videoEncoder;
+ private MicrophoneManager microphoneManager;
+ private AudioEncoder audioEncoder;
+ private boolean streaming = false;
+ protected SurfaceView surfaceView;
+ private boolean videoEnabled = true;
+ private int dpi = 320;
+ private VirtualDisplay virtualDisplay;
+ private int resultCode = -1;
+ private Intent data;
+ protected RecordController recordController;
+ private FpsListener fpsListener = new FpsListener();
+ private boolean audioInitialized = false;
+
+ public CustomDisplayBase(Context context, boolean useOpengl) {
+ this.context = context;
+ if (useOpengl) {
+ glInterface = new OffScreenGlThread(context);
+ glInterface.init();
+ }
+ mediaProjectionManager =
+ ((MediaProjectionManager) context.getSystemService(MEDIA_PROJECTION_SERVICE));
+ this.surfaceView = null;
+ videoEncoder = new VideoEncoder(this);
+ audioEncoder = new AudioEncoder(this);
+ //Necessary use same thread to read input buffer and encode it with internal audio or audio is choppy.
+ setMicrophoneMode(MicrophoneMode.SYNC);
+ recordController = new RecordController();
+ }
+
+ /**
+ * Must be called before prepareAudio.
+ *
+ * @param microphoneMode mode to work accord to audioEncoder. By default SYNC:
+ * SYNC using same thread. This mode could solve choppy audio or audio frame discarded.
+ * ASYNC using other thread.
+ */
+ public void setMicrophoneMode(MicrophoneMode microphoneMode) {
+ switch (microphoneMode) {
+ case SYNC:
+ microphoneManager = new MicrophoneManagerManual();
+ audioEncoder = new AudioEncoder(this);
+ audioEncoder.setGetFrame(((MicrophoneManagerManual) microphoneManager).getGetFrame());
+ break;
+ case ASYNC:
+ microphoneManager = new MicrophoneManager(this);
+ audioEncoder = new AudioEncoder(this);
+ break;
+ }
+ }
+
+ /**
+ * Set an audio effect modifying microphone's PCM buffer.
+ */
+ public void setCustomAudioEffect(CustomAudioEffect customAudioEffect) {
+ microphoneManager.setCustomAudioEffect(customAudioEffect);
+ }
+
+ /**
+ * @param callback get fps while record or stream
+ */
+ public void setFpsListener(FpsListener.Callback callback) {
+ fpsListener.setCallback(callback);
+ }
+
+ /**
+ * Basic auth developed to work with Wowza. No tested with other server
+ *
+ * @param user auth.
+ * @param password auth.
+ */
+ public abstract void setAuthorization(String user, String password);
+
+ /**
+ * Call this method before use @startStream. If not you will do a stream without video.
+ *
+ * @param width resolution in px.
+ * @param height resolution in px.
+ * @param fps frames per second of the stream.
+ * @param bitrate H264 in bps.
+ * @param rotation could be 90, 180, 270 or 0 (Normally 0 if you are streaming in landscape or 90
+ * if you are streaming in Portrait). This only affect to stream result. This work rotating with
+ * encoder.
+ * NOTE: Rotation with encoder is silence ignored in some devices.
+ * @param dpi of your screen device.
+ * @return true if success, false if you get a error (Normally because the encoder selected
+ * doesn't support any configuration seated or your device hasn't a H264 encoder).
+ */
+ public boolean prepareVideo(int width, int height, int fps, int bitrate, int rotation, int dpi,
+ int avcProfile, int avcProfileLevel, int iFrameInterval) {
+ this.dpi = dpi;
+ boolean result =
+ videoEncoder.prepareVideoEncoder(width, height, fps, bitrate, rotation, iFrameInterval,
+ FormatVideoEncoder.SURFACE, avcProfile, avcProfileLevel);
+ if (glInterface != null) {
+ if (rotation == 90 || rotation == 270) {
+ glInterface.setEncoderSize(videoEncoder.getHeight(), videoEncoder.getWidth());
+ } else {
+ glInterface.setEncoderSize(videoEncoder.getWidth(), videoEncoder.getHeight());
+ }
+ }
+ return result;
+ }
+
+ public boolean prepareVideo(int width, int height, int fps, int bitrate, int rotation, int dpi) {
+ return prepareVideo(width, height, fps, bitrate, rotation, dpi, -1, -1, 2);
+ }
+
+ public boolean prepareVideo(int width, int height, int bitrate) {
+ return prepareVideo(width, height, 30, bitrate, 0, 320);
+ }
+
+ protected abstract void prepareAudioRtp(boolean isStereo, int sampleRate);
+
+ /**
+ * Call this method before use @startStream. If not you will do a stream without audio.
+ *
+ * @param bitrate AAC in kb.
+ * @param sampleRate of audio in hz. Can be 8000, 16000, 22500, 32000, 44100.
+ * @param isStereo true if you want Stereo audio (2 audio channels), false if you want Mono audio
+ * (1 audio channel).
+ * @param echoCanceler true enable echo canceler, false disable.
+ * @param noiseSuppressor true enable noise suppressor, false disable.
+ * @return true if success, false if you get a error (Normally because the encoder selected
+ * doesn't support any configuration seated or your device hasn't a AAC encoder).
+ */
+ public boolean prepareAudio(int audioSource, int bitrate, int sampleRate, boolean isStereo, boolean echoCanceler,
+ boolean noiseSuppressor) {
+ if (!microphoneManager.createMicrophone(audioSource, sampleRate, isStereo, echoCanceler, noiseSuppressor)) {
+ return false;
+ }
+ prepareAudioRtp(isStereo, sampleRate);
+ audioInitialized = audioEncoder.prepareAudioEncoder(bitrate, sampleRate, isStereo,
+ microphoneManager.getMaxInputSize());
+ return audioInitialized;
+ }
+
+ public boolean prepareAudio(int bitrate, int sampleRate, boolean isStereo, boolean echoCanceler,
+ boolean noiseSuppressor) {
+ return prepareAudio(MediaRecorder.AudioSource.DEFAULT, bitrate, sampleRate, isStereo, echoCanceler,
+ noiseSuppressor);
+ }
+
+ public boolean prepareAudio(int bitrate, int sampleRate, boolean isStereo) {
+ return prepareAudio(bitrate, sampleRate, isStereo, false, false);
+ }
+
+ /**
+ * Call this method before use @startStream for streaming internal audio only.
+ *
+ * @param bitrate AAC in kb.
+ * @param sampleRate of audio in hz. Can be 8000, 16000, 22500, 32000, 44100.
+ * @param isStereo true if you want Stereo audio (2 audio channels), false if you want Mono audio
+ * (1 audio channel).
+ * @see AudioPlaybackCaptureConfiguration.Builder#Builder(MediaProjection)
+ */
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ public boolean prepareInternalAudio(int bitrate, int sampleRate, boolean isStereo,
+ boolean echoCanceler, boolean noiseSuppressor) {
+ if (mediaProjection == null) {
+ mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data);
+ }
+
+ AudioPlaybackCaptureConfiguration config =
+ new AudioPlaybackCaptureConfiguration.Builder(mediaProjection).addMatchingUsage(
+ AudioAttributes.USAGE_MEDIA)
+ .addMatchingUsage(AudioAttributes.USAGE_GAME)
+ .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
+ .build();
+ if (!microphoneManager.createInternalMicrophone(config, sampleRate, isStereo, echoCanceler,
+ noiseSuppressor)) {
+ return false;
+ }
+ prepareAudioRtp(isStereo, sampleRate);
+ audioInitialized = audioEncoder.prepareAudioEncoder(bitrate, sampleRate, isStereo,
+ microphoneManager.getMaxInputSize());
+ return audioInitialized;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ public boolean prepareInternalAudio(int bitrate, int sampleRate, boolean isStereo) {
+ return prepareInternalAudio(bitrate, sampleRate, isStereo, false, false);
+ }
+
+ /**
+ * Same to call:
+ * rotation = 0;
+ * if (Portrait) rotation = 90;
+ * prepareVideo(640, 480, 30, 1200 * 1024, true, 0);
+ *
+ * @return true if success, false if you get a error (Normally because the encoder selected
+ * doesn't support any configuration seated or your device hasn't a H264 encoder).
+ */
+ public boolean prepareVideo() {
+ return prepareVideo(640, 480, 30, 1200 * 1024, 0, 320);
+ }
+
+ /**
+ * Same to call:
+ * prepareAudio(64 * 1024, 32000, true, false, false);
+ *
+ * @return true if success, false if you get a error (Normally because the encoder selected
+ * doesn't support any configuration seated or your device hasn't a AAC encoder).
+ */
+ public boolean prepareAudio() {
+ return prepareAudio(64 * 1024, 32000, true, false, false);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ public boolean prepareInternalAudio() {
+ return prepareInternalAudio(64 * 1024, 32000, true);
+ }
+
+ /**
+ * @param forceVideo force type codec used. FIRST_COMPATIBLE_FOUND, SOFTWARE, HARDWARE
+ * @param forceAudio force type codec used. FIRST_COMPATIBLE_FOUND, SOFTWARE, HARDWARE
+ */
+ public void setForce(CodecUtil.Force forceVideo, CodecUtil.Force forceAudio) {
+ videoEncoder.setForce(forceVideo);
+ audioEncoder.setForce(forceAudio);
+ }
+
+ /**
+ * Starts recording an MP4 video. Needs to be called while streaming.
+ *
+ * @param path Where file will be saved.
+ * @throws IOException If initialized before a stream.
+ */
+ public void startRecord(@NonNull String path, @Nullable RecordController.Listener listener)
+ throws IOException {
+ recordController.startRecord(path, listener);
+ if (!streaming) {
+ startEncoders(resultCode, data);
+ } else if (videoEncoder.isRunning()) {
+ resetVideoEncoder();
+ }
+ }
+
+ public void startRecord(@NonNull final String path) throws IOException {
+ startRecord(path, null);
+ }
+
+ /**
+ * Starts recording an MP4 video. Needs to be called while streaming.
+ *
+ * @param fd Where the file will be saved.
+ * @throws IOException If initialized before a stream.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ public void startRecord(@NonNull final FileDescriptor fd,
+ @Nullable RecordController.Listener listener) throws IOException {
+ recordController.startRecord(fd, listener);
+ if (!streaming) {
+ startEncoders(resultCode, data);
+ } else if (videoEncoder.isRunning()) {
+ resetVideoEncoder();
+ }
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ public void startRecord(@NonNull final FileDescriptor fd) throws IOException {
+ startRecord(fd, null);
+ }
+
+ /**
+ * Stop record MP4 video started with @startRecord. If you don't call it file will be unreadable.
+ */
+ public void stopRecord() {
+ recordController.stopRecord();
+ if (!streaming) stopStream();
+ }
+
+ protected abstract void startStreamRtp(String url);
+
+ /**
+ * Create Intent used to init screen capture with startActivityForResult.
+ *
+ * @return intent to startActivityForResult.
+ */
+ public Intent sendIntent() {
+ return mediaProjectionManager.createScreenCaptureIntent();
+ }
+
+ public void setIntentResult(int resultCode, Intent data) {
+ this.resultCode = resultCode;
+ this.data = data;
+ }
+
+ /**
+ * Need be called after @prepareVideo or/and @prepareAudio.
+ *
+ * @param url of the stream like:
+ * protocol://ip:port/application/streamName
+ *
+ * RTSP: rtsp://192.168.1.1:1935/live/pedroSG94
+ * RTSPS: rtsps://192.168.1.1:1935/live/pedroSG94
+ * RTMP: rtmp://192.168.1.1:1935/live/pedroSG94
+ * RTMPS: rtmps://192.168.1.1:1935/live/pedroSG94
+ */
+ public void startStream(String url) {
+ streaming = true;
+ if (!recordController.isRunning()) {
+ startEncoders(resultCode, data);
+ } else {
+ resetVideoEncoder();
+ }
+ startStreamRtp(url);
+ }
+
+ private void startEncoders(int resultCode, Intent data) {
+ if (data == null) {
+ throw new RuntimeException("You need send intent data before startRecord or startStream");
+ }
+ videoEncoder.start();
+ if (audioInitialized) audioEncoder.start();
+ if (glInterface != null) {
+ glInterface.init();
+ glInterface.setFps(videoEncoder.getFps());
+ glInterface.start();
+ glInterface.addMediaCodecSurface(videoEncoder.getInputSurface());
+ }
+ Surface surface =
+ (glInterface != null) ? glInterface.getSurface() : videoEncoder.getInputSurface();
+ if (mediaProjection == null) {
+ mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data);
+ }
+ int flags = 0;
+ flags |= DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR
+ | DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION
+ | DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY;
+
+ if (glInterface != null && videoEncoder.getRotation() == 90
+ || videoEncoder.getRotation() == 270) {
+ // Use VIRTUAL_DISPLAY_FLAG_ROTATES_WITH_CONTENT to force stream capture rotate 1 << 7
+ flags |= 1 << 7;
+
+ virtualDisplay =
+ mediaProjection.createVirtualDisplay("Stream Display", videoEncoder.getHeight(),
+ videoEncoder.getWidth(), dpi, flags, surface, null, null);
+ } else {
+ virtualDisplay =
+ mediaProjection.createVirtualDisplay("Stream Display", videoEncoder.getWidth(),
+ videoEncoder.getHeight(), dpi, flags, surface, null, null);
+ }
+ if (audioInitialized) microphoneManager.start();
+ }
+
+ private void resetVideoEncoder() {
+ virtualDisplay.setSurface(null);
+ if (glInterface != null) {
+ glInterface.removeMediaCodecSurface();
+ }
+ videoEncoder.forceKeyFrame();
+ if (glInterface != null) {
+ glInterface.addMediaCodecSurface(videoEncoder.getInputSurface());
+ }
+ virtualDisplay.setSurface(
+ glInterface != null ? glInterface.getSurface() : videoEncoder.getInputSurface());
+ }
+
+ protected abstract void stopStreamRtp();
+
+ /**
+ * Stop stream started with @startStream.
+ */
+ public void stopStream() {
+ if (streaming) {
+ streaming = false;
+ stopStreamRtp();
+ }
+ if (!recordController.isRecording()) {
+ if (audioInitialized) microphoneManager.stop();
+ if (mediaProjection != null) {
+ mediaProjection.stop();
+ }
+ if (glInterface != null) {
+ glInterface.removeMediaCodecSurface();
+ glInterface.stop();
+ }
+ videoEncoder.stop();
+ audioEncoder.stop();
+ data = null;
+ recordController.resetFormats();
+ }
+ }
+
+ public boolean reTry(long delay, String reason) {
+ boolean result = shouldRetry(reason);
+ if (result) {
+ reTry(delay);
+ }
+ return result;
+ }
+
+ /**
+ * Replace with reTry(long delay, String reason);
+ */
+ @Deprecated
+ public void reTry(long delay) {
+ resetVideoEncoder();
+ reConnect(delay);
+ }
+
+ /**
+ * Replace with reTry(long delay, String reason);
+ */
+ @Deprecated
+ public abstract boolean shouldRetry(String reason);
+
+ public abstract void setReTries(int reTries);
+
+ protected abstract void reConnect(long delay);
+
+ //cache control
+ public abstract boolean hasCongestion();
+
+ public abstract void resizeCache(int newSize) throws RuntimeException;
+
+ public abstract int getCacheSize();
+
+ public abstract long getSentAudioFrames();
+
+ public abstract long getSentVideoFrames();
+
+ public abstract long getDroppedAudioFrames();
+
+ public abstract long getDroppedVideoFrames();
+
+ public abstract void resetSentAudioFrames();
+
+ public abstract void resetSentVideoFrames();
+
+ public abstract void resetDroppedAudioFrames();
+
+ public abstract void resetDroppedVideoFrames();
+
+ public GlInterface getGlInterface() {
+ if (glInterface != null) {
+ return glInterface;
+ } else {
+ throw new RuntimeException("You can't do it. You are not using Opengl");
+ }
+ }
+
+ /**
+ * Mute microphone, can be called before, while and after stream.
+ */
+ public void disableAudio() {
+ if (audioInitialized) {
+ microphoneManager.mute();
+ }
+ }
+
+ /**
+ * Enable a muted microphone, can be called before, while and after stream.
+ */
+ public void enableAudio() {
+ if (audioInitialized) {
+ microphoneManager.unMute();
+ }
+ }
+
+ /**
+ * Get mute state of microphone.
+ *
+ * @return true if muted, false if enabled
+ */
+ public boolean isAudioMuted() {
+ return microphoneManager.isMuted();
+ }
+
+ /**
+ * Get video camera state
+ *
+ * @return true if disabled, false if enabled
+ */
+ public boolean isVideoEnabled() {
+ return videoEnabled;
+ }
+
+ public int getBitrate() {
+ return videoEncoder.getBitRate();
+ }
+
+ public int getResolutionValue() {
+ return videoEncoder.getWidth() * videoEncoder.getHeight();
+ }
+
+ public int getStreamWidth() {
+ return videoEncoder.getWidth();
+ }
+
+ public int getStreamHeight() {
+ return videoEncoder.getHeight();
+ }
+
+ /**
+ * Set video bitrate of H264 in bits per second while stream.
+ *
+ * @param bitrate H264 in bits per second.
+ */
+ public void setVideoBitrateOnFly(int bitrate) {
+ videoEncoder.setVideoBitrateOnFly(bitrate);
+ }
+
+ /**
+ * Set limit FPS while stream. This will be override when you call to prepareVideo method.
+ * This could produce a change in iFrameInterval.
+ *
+ * @param fps frames per second
+ */
+ public void setLimitFPSOnFly(int fps) {
+ videoEncoder.setFps(fps);
+ }
+
+ /**
+ * Get stream state.
+ *
+ * @return true if streaming, false if not streaming.
+ */
+ public boolean isStreaming() {
+ return streaming;
+ }
+
+ /**
+ * Get record state.
+ *
+ * @return true if recording, false if not recoding.
+ */
+ public boolean isRecording() {
+ return recordController.isRunning();
+ }
+
+ public void pauseRecord() {
+ recordController.pauseRecord();
+ }
+
+ public void resumeRecord() {
+ recordController.resumeRecord();
+ }
+
+ public RecordController.Status getRecordStatus() {
+ return recordController.getStatus();
+ }
+
+ protected abstract void getAacDataRtp(ByteBuffer aacBuffer, MediaCodec.BufferInfo info);
+
+ @Override
+ public void getAacData(ByteBuffer aacBuffer, MediaCodec.BufferInfo info) {
+ recordController.recordAudio(aacBuffer, info);
+ if (streaming) getAacDataRtp(aacBuffer, info);
+ }
+
+ protected abstract void onSpsPpsVpsRtp(ByteBuffer sps, ByteBuffer pps, ByteBuffer vps);
+
+ @Override
+ public void onSpsPps(ByteBuffer sps, ByteBuffer pps) {
+ if (streaming) onSpsPpsVpsRtp(sps, pps, null);
+ }
+
+ @Override
+ public void onSpsPpsVps(ByteBuffer sps, ByteBuffer pps, ByteBuffer vps) {
+ if (streaming) onSpsPpsVpsRtp(sps, pps, vps);
+ }
+
+ protected abstract void getH264DataRtp(ByteBuffer h264Buffer, MediaCodec.BufferInfo info);
+
+ @Override
+ public void getVideoData(ByteBuffer h264Buffer, MediaCodec.BufferInfo info) {
+ fpsListener.calculateFps();
+ recordController.recordVideo(h264Buffer, info);
+ if (streaming) getH264DataRtp(h264Buffer, info);
+ }
+
+ @Override
+ public void inputPCMData(Frame frame) {
+ audioEncoder.inputPCMData(frame);
+ }
+
+ @Override
+ public void onVideoFormat(MediaFormat mediaFormat) {
+ recordController.setVideoFormat(mediaFormat);
+ }
+
+ @Override
+ public void onAudioFormat(MediaFormat mediaFormat) {
+ recordController.setAudioFormat(mediaFormat);
+ }
+
+ public abstract void setLogs(boolean enable);
+}
+
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/CustomRtmpDisplay.java b/app/src/main/java/com/fpvout/digiview/streaming/CustomRtmpDisplay.java
new file mode 100644
index 0000000..eae4258
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/CustomRtmpDisplay.java
@@ -0,0 +1,159 @@
+package com.fpvout.digiview.streaming;
+
+import android.content.Context;
+import android.media.MediaCodec;
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+
+import net.ossrs.rtmp.ConnectCheckerRtmp;
+import net.ossrs.rtmp.SrsFlvMuxer;
+
+import java.nio.ByteBuffer;
+
+@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+public class CustomRtmpDisplay extends CustomDisplayBase {
+
+ private SrsFlvMuxer srsFlvMuxer;
+
+ public CustomRtmpDisplay(Context context, boolean useOpengl, ConnectCheckerRtmp connectChecker) {
+ super(context, useOpengl);
+ srsFlvMuxer = new SrsFlvMuxer(connectChecker);
+ }
+
+ /**
+ * H264 profile.
+ *
+ * @param profileIop Could be ProfileIop.BASELINE or ProfileIop.CONSTRAINED
+ */
+ public void setProfileIop(byte profileIop) {
+ srsFlvMuxer.setProfileIop(profileIop);
+ }
+
+ @Override
+ public void resizeCache(int newSize) throws RuntimeException {
+ srsFlvMuxer.resizeFlvTagCache(newSize);
+ }
+
+ @Override
+ public int getCacheSize() {
+ return srsFlvMuxer.getFlvTagCacheSize();
+ }
+
+ @Override
+ public long getSentAudioFrames() {
+ return srsFlvMuxer.getSentAudioFrames();
+ }
+
+ @Override
+ public long getSentVideoFrames() {
+ return srsFlvMuxer.getSentVideoFrames();
+ }
+
+ @Override
+ public long getDroppedAudioFrames() {
+ return srsFlvMuxer.getDroppedAudioFrames();
+ }
+
+ @Override
+ public long getDroppedVideoFrames() {
+ return srsFlvMuxer.getDroppedVideoFrames();
+ }
+
+ @Override
+ public void resetSentAudioFrames() {
+ srsFlvMuxer.resetSentAudioFrames();
+ }
+
+ @Override
+ public void resetSentVideoFrames() {
+ srsFlvMuxer.resetSentVideoFrames();
+ }
+
+ @Override
+ public void resetDroppedAudioFrames() {
+ srsFlvMuxer.resetDroppedAudioFrames();
+ }
+
+ @Override
+ public void resetDroppedVideoFrames() {
+ srsFlvMuxer.resetDroppedVideoFrames();
+ }
+
+ @Override
+ public void setAuthorization(String user, String password) {
+ srsFlvMuxer.setAuthorization(user, password);
+ }
+
+ /**
+ * Some Livestream hosts use Akamai auth that requires RTMP packets to be sent with increasing
+ * timestamp order regardless of packet type.
+ * Necessary with Servers like Dacast.
+ * More info here:
+ * https://learn.akamai.com/en-us/webhelp/media-services-live/media-services-live-encoder-compatibility-testing-and-qualification-guide-v4.0/GUID-F941C88B-9128-4BF4-A81B-C2E5CFD35BBF.html
+ */
+ public void forceAkamaiTs(boolean enabled) {
+ srsFlvMuxer.forceAkamaiTs(enabled);
+ }
+
+ @Override
+ protected void prepareAudioRtp(boolean isStereo, int sampleRate) {
+ srsFlvMuxer.setIsStereo(isStereo);
+ srsFlvMuxer.setSampleRate(sampleRate);
+ }
+
+ @Override
+ protected void startStreamRtp(String url) {
+ if (videoEncoder.getRotation() == 90 || videoEncoder.getRotation() == 270) {
+ srsFlvMuxer.setVideoResolution(videoEncoder.getHeight(), videoEncoder.getWidth());
+ } else {
+ srsFlvMuxer.setVideoResolution(videoEncoder.getWidth(), videoEncoder.getHeight());
+ }
+ srsFlvMuxer.start(url);
+ }
+
+ @Override
+ protected void stopStreamRtp() {
+ srsFlvMuxer.stop();
+ }
+
+ @Override
+ public void setReTries(int reTries) {
+ srsFlvMuxer.setReTries(reTries);
+ }
+
+ @Override
+ public boolean shouldRetry(String reason) {
+ return srsFlvMuxer.shouldRetry(reason);
+ }
+
+ @Override
+ public void reConnect(long delay) {
+ srsFlvMuxer.reConnect(delay);
+ }
+
+ @Override
+ public boolean hasCongestion() {
+ return srsFlvMuxer.hasCongestion();
+ }
+
+ @Override
+ protected void getAacDataRtp(ByteBuffer aacBuffer, MediaCodec.BufferInfo info) {
+ srsFlvMuxer.sendAudio(aacBuffer, info);
+ }
+
+ @Override
+ protected void onSpsPpsVpsRtp(ByteBuffer sps, ByteBuffer pps, ByteBuffer vps) {
+ srsFlvMuxer.setSpsPPs(sps, pps);
+ }
+
+ @Override
+ protected void getH264DataRtp(ByteBuffer h264Buffer, MediaCodec.BufferInfo info) {
+ srsFlvMuxer.sendVideo(h264Buffer, info);
+ }
+
+ @Override
+ public void setLogs(boolean enable) {
+ srsFlvMuxer.setLogs(enable);
+ }
+}
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioBitrate.java b/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioBitrate.java
new file mode 100644
index 0000000..e120e1a
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioBitrate.java
@@ -0,0 +1,9 @@
+package com.fpvout.digiview.streaming;
+
+public class StreamAudioBitrate {
+ public static final String DEFAULT = "128";
+
+ public static int getBitrate(String value) {
+ return Integer.parseInt(value) * 1024;
+ }
+}
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioSampleRate.java b/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioSampleRate.java
new file mode 100644
index 0000000..5404322
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioSampleRate.java
@@ -0,0 +1,28 @@
+package com.fpvout.digiview.streaming;
+
+public class StreamAudioSampleRate {
+ public static final String DEFAULT = "44100hz";
+
+ public static int getSampleRate(String value) {
+ switch (value) {
+ case "8khz":
+ return 8000;
+ case "11025hz":
+ return 11025;
+ case "16khz":
+ return 16000;
+ case "22050hz":
+ return 22050;
+ case "32000hz":
+ return 32000;
+ case DEFAULT:
+ return 44100;
+ case "48khz":
+ return 48000;
+ case "96khz":
+ return 96000;
+ }
+
+ return -1;
+ }
+}
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioSource.java b/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioSource.java
new file mode 100644
index 0000000..21e7ab1
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/StreamAudioSource.java
@@ -0,0 +1,26 @@
+package com.fpvout.digiview.streaming;
+
+import android.media.MediaRecorder;
+
+public class StreamAudioSource {
+ public static final String DEFAULT = "default";
+ public static final String INTERNAL = "internal";
+ public static final String PERFORMANCE = "performance";
+
+ public static int getAudioSource(String value) {
+ switch (value) {
+ case DEFAULT:
+ return MediaRecorder.AudioSource.DEFAULT;
+ case "mic":
+ return MediaRecorder.AudioSource.MIC;
+ case "cam":
+ return MediaRecorder.AudioSource.CAMCORDER;
+ case "communication":
+ return MediaRecorder.AudioSource.VOICE_COMMUNICATION;
+ case PERFORMANCE:
+ return MediaRecorder.AudioSource.VOICE_PERFORMANCE;
+ }
+
+ return -1;
+ }
+}
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/StreamBitrate.java b/app/src/main/java/com/fpvout/digiview/streaming/StreamBitrate.java
new file mode 100644
index 0000000..eae699e
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/StreamBitrate.java
@@ -0,0 +1,9 @@
+package com.fpvout.digiview.streaming;
+
+public class StreamBitrate {
+ public static final String DEFAULT = "2500";
+
+ public static int getBitrate(String value) {
+ return Integer.parseInt(value) * 1024;
+ }
+}
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/StreamFramerate.java b/app/src/main/java/com/fpvout/digiview/streaming/StreamFramerate.java
new file mode 100644
index 0000000..3087404
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/StreamFramerate.java
@@ -0,0 +1,9 @@
+package com.fpvout.digiview.streaming;
+
+public class StreamFramerate {
+ public static final String DEFAULT = "60";
+
+ public static int getFramerate(String value) {
+ return Integer.parseInt(value);
+ }
+}
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/StreamResolution.java b/app/src/main/java/com/fpvout/digiview/streaming/StreamResolution.java
new file mode 100644
index 0000000..fb74a43
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/StreamResolution.java
@@ -0,0 +1,37 @@
+package com.fpvout.digiview.streaming;
+
+public class StreamResolution {
+ public static final String DEFAULT = "720p";
+ private final int width;
+ private final int height;
+
+ private StreamResolution(int width, int height) {
+ this.width = width;
+ this.height = height;
+ }
+
+ public static StreamResolution getResolution(String value) {
+ switch (value) {
+ case "240p":
+ return new StreamResolution(426, 240);
+ case "360p":
+ return new StreamResolution(640, 360);
+ case "480p":
+ return new StreamResolution(854, 480);
+ case DEFAULT:
+ return new StreamResolution(1280, 720);
+ case "1080p":
+ return new StreamResolution(1920, 1080);
+ }
+
+ return null;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+}
diff --git a/app/src/main/java/com/fpvout/digiview/streaming/StreamingService.java b/app/src/main/java/com/fpvout/digiview/streaming/StreamingService.java
new file mode 100644
index 0000000..08949e2
--- /dev/null
+++ b/app/src/main/java/com/fpvout/digiview/streaming/StreamingService.java
@@ -0,0 +1,186 @@
+package com.fpvout.digiview.streaming;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.DisplayMetrics;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+import androidx.preference.PreferenceManager;
+
+import net.ossrs.rtmp.ConnectCheckerRtmp;
+
+public class StreamingService extends Service {
+ private static final String TAG = "Streaming-Service";
+ private static NotificationManager notificationManager;
+ private static SharedPreferences sharedPreferences;
+ private static final String channelId = "streamingServiceNotification";
+ private static Context appContext;
+ private static ConnectCheckerRtmp connectChecker;
+ private static Intent mediaProjectionData;
+ private static int mediaProjectionResultCode;
+ private static CustomRtmpDisplay rtmpDisplayBase;
+ private static int dpi;
+ private String endpoint;
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ Log.d(TAG, "Create");
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
+ notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_HIGH);
+ notificationManager.createNotificationChannel(channel);
+ }
+ keepAliveTrick();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ Log.d(TAG, "Start");
+ endpoint = String.format("%s/%s", sharedPreferences.getString("StreamRtmpUrl", ""), sharedPreferences.getString("StreamRtmpKey", ""));
+ prepareStreaming();
+ startStreaming();
+
+ return START_STICKY;
+ }
+
+ private void prepareStreaming() {
+ stopStreaming();
+ rtmpDisplayBase = new CustomRtmpDisplay(appContext, true, connectChecker);
+ if (!sharedPreferences.getString("StreamRtmpUsername", "").isEmpty() && !sharedPreferences.getString("StreamRtmpPassword", "").isEmpty()) {
+ rtmpDisplayBase.setAuthorization(sharedPreferences.getString("StreamRtmpUsername", ""), sharedPreferences.getString("StreamRtmpPassword", ""));
+ }
+ rtmpDisplayBase.setIntentResult(mediaProjectionResultCode, mediaProjectionData);
+ }
+
+ private void startStreaming() {
+ if (!rtmpDisplayBase.isStreaming()) {
+ StreamResolution streamResolution = StreamResolution.getResolution(sharedPreferences.getString("StreamResolution", StreamResolution.DEFAULT));
+ if (rtmpDisplayBase.prepareVideo(
+ streamResolution.getWidth(),
+ streamResolution.getHeight(),
+ StreamFramerate.getFramerate(sharedPreferences.getString("StreamFramerate", StreamFramerate.DEFAULT)),
+ StreamBitrate.getBitrate(sharedPreferences.getString("StreamBitrate", StreamBitrate.DEFAULT)),
+ sharedPreferences.getBoolean("StreamPortrait", false) ? 90 : 0,
+ dpi
+ )) {
+ boolean audioInitialized;
+ if (sharedPreferences.getString("StreamAudioSource", StreamAudioSource.DEFAULT).equals(StreamAudioSource.INTERNAL) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ audioInitialized = rtmpDisplayBase.prepareInternalAudio(
+ StreamAudioBitrate.getBitrate(sharedPreferences.getString("StreamAudioBitrate", StreamAudioBitrate.DEFAULT)),
+ StreamAudioSampleRate.getSampleRate(sharedPreferences.getString("StreamAudioSampleRate", StreamAudioSampleRate.DEFAULT)),
+ sharedPreferences.getBoolean("StreamAudioStereo", true),
+ false,
+ false
+ );
+ } else {
+ audioInitialized = rtmpDisplayBase.prepareAudio(
+ StreamAudioSource.getAudioSource(sharedPreferences.getString("StreamAudioSource", StreamAudioSource.DEFAULT)),
+ StreamAudioBitrate.getBitrate(sharedPreferences.getString("StreamAudioBitrate", StreamAudioBitrate.DEFAULT)),
+ StreamAudioSampleRate.getSampleRate(sharedPreferences.getString("StreamAudioSampleRate", StreamAudioSampleRate.DEFAULT)),
+ sharedPreferences.getBoolean("StreamAudioStereo", true),
+ false,
+ false
+ );
+ }
+
+ if (audioInitialized) {
+ if (!sharedPreferences.getBoolean("StreamRecordAudio", true)) {
+ rtmpDisplayBase.disableAudio();
+ } else {
+ rtmpDisplayBase.enableAudio();
+ }
+
+ rtmpDisplayBase.startStream(endpoint);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ stopStreaming();
+ }
+
+ private void keepAliveTrick() {
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
+ Notification notification = new NotificationCompat.Builder(this, channelId)
+ .setOngoing(true)
+ .setContentTitle("")
+ .setContentText("").build();
+ startForeground(1, notification);
+ } else {
+ startForeground(1, new Notification());
+ }
+ }
+
+ public static boolean isStreaming() {
+ return rtmpDisplayBase != null && rtmpDisplayBase.isStreaming();
+ }
+
+ public static boolean isMuted() {
+ return rtmpDisplayBase != null && rtmpDisplayBase.isAudioMuted();
+ }
+
+ public static void toggleMute() {
+ if (isStreaming()) {
+ if (rtmpDisplayBase.isAudioMuted()) {
+ rtmpDisplayBase.enableAudio();
+ } else {
+ rtmpDisplayBase.disableAudio();
+ }
+ }
+ }
+
+ public static void init(Context context, ConnectCheckerRtmp connectCheckerRtmp) {
+ appContext = context;
+ connectChecker = connectCheckerRtmp;
+
+ DisplayMetrics dm = context.getResources().getDisplayMetrics();
+ dpi = dm.densityDpi;
+
+ if (rtmpDisplayBase == null) {
+ sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext);
+ rtmpDisplayBase = new CustomRtmpDisplay(appContext, true, connectChecker);
+ if (!sharedPreferences.getString("StreamRtmpUsername", "").isEmpty() && !sharedPreferences.getString("StreamRtmpPassword", "").isEmpty()) {
+ rtmpDisplayBase.setAuthorization(sharedPreferences.getString("StreamRtmpUsername", ""), sharedPreferences.getString("StreamRtmpPassword", ""));
+ }
+ }
+ }
+
+ public static void setMediaProjectionData(int resultCode, Intent data) {
+ mediaProjectionResultCode = resultCode;
+ mediaProjectionData = data;
+ }
+
+ public static Intent sendIntent() {
+ if (rtmpDisplayBase != null) {
+ return rtmpDisplayBase.sendIntent();
+ }
+
+ return null;
+ }
+
+ public static void stopStreaming() {
+ if (StreamingService.isStreaming()) {
+ rtmpDisplayBase.stopStream();
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_live_icon.xml b/app/src/main/res/drawable/ic_live_icon.xml
new file mode 100644
index 0000000..6d63b47
--- /dev/null
+++ b/app/src/main/res/drawable/ic_live_icon.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_microphone_slash_solid.xml b/app/src/main/res/drawable/ic_microphone_slash_solid.xml
new file mode 100644
index 0000000..f3a98bc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_microphone_slash_solid.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_microphone_solid.xml b/app/src/main/res/drawable/ic_microphone_solid.xml
new file mode 100644
index 0000000..933783d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_microphone_solid.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/streaming_pixel.xml b/app/src/main/res/drawable/streaming_pixel.xml
new file mode 100644
index 0000000..8a6bbae
--- /dev/null
+++ b/app/src/main/res/drawable/streaming_pixel.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/streaming_pixel_off.png b/app/src/main/res/drawable/streaming_pixel_off.png
new file mode 100644
index 0000000..ab39995
Binary files /dev/null and b/app/src/main/res/drawable/streaming_pixel_off.png differ
diff --git a/app/src/main/res/drawable/streaming_pixel_on.png b/app/src/main/res/drawable/streaming_pixel_on.png
new file mode 100644
index 0000000..86bb1da
Binary files /dev/null and b/app/src/main/res/drawable/streaming_pixel_on.png differ
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 3d2145d..5064b3c 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -19,6 +19,15 @@
app:layout_constraintTop_toTopOf="parent">
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 8b50f25..a969ae2 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -40,4 +40,30 @@
Copyright
Open-Source Lizenz
MIT Lizenz
+ Streaming
+ RTMP Url
+ RTMP Key
+ RTMP Username
+ RTMP Password
+ Record audio
+ Audio source
+ Default
+ Mic
+ Camera
+ Communication
+ Performance
+ Internal audio
+ Output Resolution
+ Output Framerate
+ Output Max Bitrate
+ Failed to connect to RTMP server
+ RTMP authentication failed
+ Please, check your streaming RTMP URL and Key
+ Audio recording can be muted during live
+ Stereo audio
+ Only if your device or the audio source supports it
+ Audio sample rate
+ Audio bitrate
+ Force portrait mode
+ Your video stream will be rotated in portrait mode
\ No newline at end of file
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index c6ccf2e..143fe17 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -39,4 +39,30 @@
Copyright
Licencia Open-Source
Licencia MIT
+ Streaming
+ RTMP Url
+ RTMP Key
+ RTMP Username
+ RTMP Password
+ Record audio
+ Audio source
+ Default
+ Mic
+ Camera
+ Communication
+ Performance
+ Internal audio
+ Output Resolution
+ Output Framerate
+ Output Max Bitrate
+ Failed to connect to RTMP server
+ RTMP authentication failed
+ Please, check your streaming RTMP URL and Key
+ Audio recording can be muted during live
+ Stereo audio
+ Only if your device or the audio source supports it
+ Audio sample rate
+ Audio bitrate
+ Force portrait mode
+ Your video stream will be rotated in portrait mode
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index e1b4fbb..b0d59bd 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -37,4 +37,30 @@
Copyright
License Open-Source
License MIT
+ Streaming
+ RTMP Url
+ RTMP Key
+ RTMP Username
+ RTMP Password
+ Enregistrer le son
+ Source audio
+ Par défaut
+ Mic
+ Camera
+ Communication
+ Performance
+ Audio interne
+ Output Resolution
+ Output Framerate
+ Output Max Bitrate
+ Failed to connect to RTMP server
+ RTMP authentication failed
+ Please, check your streaming RTMP URL and Key
+ Audio recording can be muted during live
+ Stereo audio
+ Only if your device or the audio source supports it
+ Audio sample rate
+ Audio bitrate
+ Force portrait mode
+ Your video stream will be rotated in portrait mode
\ No newline at end of file
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index b2b9365..8907014 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -39,5 +39,31 @@
Direitos Autorais
Licença Open-Source
Licença MIT
+ Streaming
+ RTMP Url
+ RTMP Key
+ RTMP Username
+ RTMP Password
+ Record audio
+ Audio source
+ Default
+ Mic
+ Camera
+ Communication
+ Performance
+ Internal audio
+ Output Resolution
+ Output Framerate
+ Output Max Bitrate
+ Failed to connect to RTMP server
+ RTMP authentication failed
+ Please, check your streaming RTMP URL and Key
+ Audio recording can be muted during live
+ Stereo audio
+ Only if your device or the audio source supports it
+ Audio sample rate
+ Audio bitrate
+ Force portrait mode
+ Your video stream will be rotated in portrait mode
\ No newline at end of file
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 3d53462..bc7480d 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -39,5 +39,31 @@
版权所有
开源证书
MIT License
+ Streaming
+ RTMP Url
+ RTMP Key
+ RTMP Username
+ RTMP Password
+ Record audio
+ Audio source
+ Default
+ Mic
+ Camera
+ Communication
+ Performance
+ Internal audio
+ Output Resolution
+ Output Framerate
+ Output Max Bitrate
+ Failed to connect to RTMP server
+ RTMP authentication failed
+ Please, check your streaming RTMP URL and Key
+ Audio recording can be muted during live
+ Stereo audio
+ Only if your device or the audio source supports it
+ Audio sample rate
+ Audio bitrate
+ Force portrait mode
+ Your video stream will be rotated in portrait mode
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index fd59273..734d921 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -1,5 +1,4 @@
-
- @string/video_preset_default
- @string/video_preset_conservative
@@ -15,4 +14,128 @@
- legacy
- legacy_buffered
+
+
+ - @string/stream_audio_source_default
+ - @string/stream_audio_source_mic
+ - @string/stream_audio_source_cam
+ - @string/stream_audio_source_communication
+
+
+
+ - default
+ - mic
+ - cam
+ - communication
+
+
+
+ - 240p
+ - 360p
+ - 480p
+ - 720p (default)
+ - 1080p
+
+
+
+ - 240p
+ - 360p
+ - 480p
+ - 720p
+ - 1080p
+
+
+
+ - 15 fps
+ - 24 fps
+ - 25 fps
+ - 30 fps
+ - 60 fps (default)
+
+
+
+ - 15
+ - 24
+ - 25
+ - 30
+ - 60
+
+
+
+ - 300 kbps
+ - 500 kbps
+ - 700 kbps
+ - 1000 kbps
+ - 1500 kbps
+ - 2000 kbps
+ - 2500 kbps (default)
+ - 3500 kbps
+ - 4000 kbps
+ - 5000 kbps
+ - 6000 kbps
+ - 7000 kbps
+ - 8000 kbps
+ - 10000 kbps
+ - 15000 kbps
+ - 20000 kbps
+ - 25000 kbps
+
+
+
+ - 300
+ - 500
+ - 700
+ - 1000
+ - 1500
+ - 2000
+ - 2500
+ - 3500
+ - 4000
+ - 5000
+ - 6000
+ - 7000
+ - 8000
+ - 10000
+ - 15000
+ - 20000
+ - 25000
+
+
+
+ - 8 KHz
+ - 11025 Hz
+ - 16 KHz
+ - 22050 Hz
+ - 32000 Hz
+ - 44100 Hz (default)
+ - 48 KHz
+ - 96 KHz
+
+
+
+ - 8khz
+ - 11025hz
+ - 16khz
+ - 22050hz
+ - 32000hz
+ - 44100hz
+ - 48khz
+ - 96khz
+
+
+
+ - 64 kbps
+ - 96 kbps
+ - 128 kbps (default)
+ - 192 kbps
+ - 320 kbps
+
+
+
+ - 64
+ - 96
+ - 128
+ - 192
+ - 320
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dba2270..b95d7a0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -40,5 +40,31 @@
Copyright
Open-Source License
MIT License
+ Streaming
+ RTMP Url
+ RTMP Key
+ RTMP Username
+ RTMP Password
+ Record audio
+ Audio source
+ Default
+ Mic
+ Camera
+ Communication
+ Performance
+ Internal audio
+ Output Resolution
+ Output Framerate
+ Output Max Bitrate
+ Failed to connect to RTMP server
+ RTMP authentication failed
+ Please, check your streaming RTMP URL and Key
+ Audio recording can be muted during live
+ Stereo audio
+ Only if your device or the audio source supports it
+ Audio sample rate
+ Audio bitrate
+ Force portrait mode
+ Your video stream will be rotated in portrait mode
\ No newline at end of file
diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml
index 828f778..21a1669 100644
--- a/app/src/main/res/xml/root_preferences.xml
+++ b/app/src/main/res/xml/root_preferences.xml
@@ -47,6 +47,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+