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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +