diff --git a/SRT_RECEIVER_QUICKSTART.md b/SRT_RECEIVER_QUICKSTART.md new file mode 100644 index 0000000000..bdc3bc2dd2 --- /dev/null +++ b/SRT_RECEIVER_QUICKSTART.md @@ -0,0 +1,253 @@ +# SRT Receiver Implementation - Quick Start Guide + +## What Was Implemented + +A complete SRT listener/receiver for Android that implements the full pipeline: +**SRT → MPEG-TS → PES → H264/AAC → MediaCodec → SurfaceView + AudioTrack** + +## Files Created (17 total) + +### Kotlin Files (12) +1. `SrtReceiver.kt` - Main public API +2. `SrtServerSocket.kt` - JNI wrapper for SRT +3. `BlockingByteQueue.kt` - Thread-safe queue +4. `TsPacket.kt` - TS packet data class +5. `TsDemuxer.kt` - MPEG-TS demultiplexer +6. `PatParser.kt` - PAT parser +7. `PmtParser.kt` - PMT parser +8. `PesAssembler.kt` - PES assembler +9. `H264Parser.kt` - H.264 NAL parser +10. `AacParser.kt` - AAC ADTS parser +11. `VideoDecoder.kt` - H.264 MediaCodec decoder +12. `AudioDecoder.kt` - AAC MediaCodec decoder + +### C++/JNI Files (3) +13. `NativeSrt.cpp` - libsrt JNI implementation +14. `NativeSrt.h` - JNI header +15. `CMakeLists.txt` - CMake build config + +### Documentation (2) +16. `README.md` - Usage guide +17. `IMPLEMENTATION.md` - Technical details + +## How to Use + +```kotlin +import com.pedro.srtreceiver.SrtReceiver + +class MainActivity : AppCompatActivity() { + private lateinit var receiver: SrtReceiver + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val surfaceView = findViewById(R.id.surfaceView) + receiver = SrtReceiver(surfaceView) + } + + fun startReceiving() { + receiver.start(9991) // Listen on port 9991 + } + + override fun onDestroy() { + super.onDestroy() + receiver.stop() + } +} +``` + +## Sending Video to the Receiver + +From FFmpeg: +```bash +ffmpeg -re -i input.mp4 -c copy -f mpegts "srt://ANDROID_IP:9991?mode=caller" +``` + +From OBS Studio: +- Settings → Stream +- Service: Custom +- Server: `srt://ANDROID_IP:9991?mode=caller` +- Stream Key: (leave empty) + +## CRITICAL: LibSRT Dependency + +**This implementation requires libsrt to be built for Android.** + +### Option 1: Build LibSRT Yourself + +1. Clone SRT: +```bash +git clone https://github.com/Haivision/srt.git +cd srt +``` + +2. Build for Android using NDK (you need Android NDK installed): +```bash +# For each ABI: armeabi-v7a, arm64-v8a, x86, x86_64 +mkdir build-android- +cd build-android- +cmake .. \ + -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \ + -DANDROID_ABI= \ + -DANDROID_PLATFORM=android-24 \ + -DENABLE_SHARED=ON +make +``` + +3. Copy files to RootEncoder: +``` +libsrt.so files → srt/src/main/jniLibs//libsrt.so +SRT headers → srt/src/main/cpp/srt-include/srt/ +``` + +### Option 2: Use Pre-built LibSRT + +If you have pre-built libsrt binaries for Android: +1. Place `libsrt.so` in `srt/src/main/jniLibs//` +2. Place headers in `srt/src/main/cpp/srt-include/srt/` + +## Directory Structure + +``` +srt/ +├── src/main/ +│ ├── cpp/ +│ │ ├── CMakeLists.txt +│ │ ├── NativeSrt.cpp +│ │ ├── NativeSrt.h +│ │ └── srt-include/srt/ +│ │ └── srt.h (stub - replace with real headers) +│ ├── java/com/pedro/srtreceiver/ +│ │ ├── SrtReceiver.kt +│ │ ├── SrtServerSocket.kt +│ │ ├── BlockingByteQueue.kt +│ │ ├── TsPacket.kt +│ │ ├── TsDemuxer.kt +│ │ ├── PatParser.kt +│ │ ├── PmtParser.kt +│ │ ├── PesAssembler.kt +│ │ ├── H264Parser.kt +│ │ ├── AacParser.kt +│ │ ├── VideoDecoder.kt +│ │ ├── AudioDecoder.kt +│ │ ├── README.md +│ │ └── IMPLEMENTATION.md +│ └── jniLibs/ +│ ├── armeabi-v7a/ (add libsrt.so here) +│ ├── arm64-v8a/ (add libsrt.so here) +│ ├── x86/ (add libsrt.so here) +│ └── x86_64/ (add libsrt.so here) +└── build.gradle.kts (updated with NDK config) +``` + +## Build Configuration + +The `srt/build.gradle.kts` has been updated to include: +- CMake external native build +- NDK configuration for all ABIs +- C++11 standard +- Shared C++ STL + +## Permissions Required + +Add to AndroidManifest.xml: +```xml + +``` + +## Features + +✅ SRT listener mode (server) +✅ MPEG-TS demuxing +✅ H.264 video decoding +✅ AAC audio decoding +✅ Low latency (120ms) +✅ Hardware accelerated decoding +✅ Automatic reconnection +✅ Bitrate monitoring +✅ Thread-safe architecture + +## Supported Codecs + +- **Video**: H.264/AVC only (H.265 not yet supported) +- **Audio**: AAC only (MP3 not yet supported) +- **Container**: MPEG-TS + +## Limitations + +1. Requires libsrt to be manually built and integrated +2. Single client connection at a time +3. H.264 only (no HEVC/H.265) +4. AAC only (no MP3) +5. Simplified SPS parsing (defaults to 1920x1080) + +## API Level + +- Minimum: API 16 (Android 4.1) +- Recommended: API 24+ (Android 7.0+) for best MediaCodec support + +## Threading Model + +- **SRT Receive Thread**: Blocks on socket, feeds queue +- **Demux Thread**: Processes TS packets, parses streams +- **Decode Threads**: Implicit in MediaCodec (hardware) + +## Logging + +Enable logs with tag filter: +``` +adb logcat -s SrtReceiver SrtServerSocket TsDemuxer H264Parser AacParser VideoDecoder AudioDecoder +``` + +Expected output: +``` +SRT server started on port 9991 +Client connected +PMT PID updated: 4096 +Video PID updated: 256 +Audio PID updated: 257 +H264 config ready (SPS/PPS) +Video decoder started +Audio decoder started +Bitrate: 2500 kbps +``` + +## Troubleshooting + +### Native library not loaded +- Ensure libsrt.so is present for all target ABIs +- Check CMakeLists.txt paths are correct +- Verify NDK is installed + +### No video +- Check SPS/PPS received (look for "H264 config ready" in logs) +- Verify sender is encoding H.264 +- Check SurfaceView is visible + +### No audio +- Verify sender is encoding AAC +- Check "Audio decoder started" in logs +- Verify audio permissions + +### Build fails +- Ensure Android Gradle Plugin version matches your environment +- Verify CMake 3.18.1+ is available +- Check NDK installation + +## Next Steps + +1. Build and integrate libsrt +2. Build the project +3. Test with a simple MPEG-TS stream +4. Add error handling as needed +5. Customize for your use case + +## Support + +For detailed implementation information, see `IMPLEMENTATION.md`. +For usage examples, see `README.md`. + +## License + +Apache 2.0 (same as RootEncoder) diff --git a/SRT_RECEIVER_TESTING_GUIDE.md b/SRT_RECEIVER_TESTING_GUIDE.md new file mode 100644 index 0000000000..158025faa6 --- /dev/null +++ b/SRT_RECEIVER_TESTING_GUIDE.md @@ -0,0 +1,210 @@ +# SRT Receiver Testing Guide + +## Testing Setup + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SRT Streaming Test Setup │ +└─────────────────────────────────────────────────────────────────┘ + +┌──────────────────┐ ┌──────────────────┐ +│ Sender Device │ │ Android Device │ +│ (Caller Mode) │ │ (Listener Mode) │ +├──────────────────┤ ├──────────────────┤ +│ │ │ │ +│ FFmpeg/OBS │ SRT Stream (TS) │ SrtReceiver │ +│ Camera/Video │ ─────────────────────>│ Activity │ +│ │ Port 9991 (caller) │ │ +│ Encodes H.264 │ │ Decodes Video │ +│ Encodes AAC │ │ Plays Audio │ +│ │ │ │ +└──────────────────┘ └──────────────────┘ + Sender Receiver + mode=caller mode=listener +``` + +## Test Scenarios + +### Scenario 1: Android as Receiver (Listener) + +**What was implemented:** New `SrtReceiverActivity` + +1. **Android Device (Receiver - Listener Mode):** + ``` + Launch app → "SRT Receiver" → Port: 9991 → "Start Receiver" + + URL: srt://192.168.1.100:9991?mode=listener + Status: "Listening on port 9991, waiting for connection..." + ``` + +2. **Computer/Another Device (Sender - Caller Mode):** + ```bash + ffmpeg -re -i video.mp4 -c copy -f mpegts "srt://192.168.1.100:9991?mode=caller" + ``` + +3. **Expected Result:** + - Android receives SRT stream + - Video displays on SurfaceView + - Audio plays through speakers + - Status updates to show connection + +### Scenario 2: Android as Sender (Caller) - Already Exists + +**What already exists:** Existing SRT streaming activities (OldApiActivity, etc.) + +1. **Android Device (Sender - Caller Mode):** + ``` + Launch app → "Old API" or any streaming activity + Enter URL: srt://192.168.1.200:9991?mode=caller + Start camera → Start Stream + ``` + +2. **Computer/Media Player (Receiver - Listener Mode):** + ```bash + ffplay srt://0.0.0.0:9991?mode=listener + ``` + +3. **Expected Result:** + - Android streams camera to remote player + - Remote player displays video/audio + +## Demo Activity Features + +### SrtReceiverActivity UI Components + +``` +┌─────────────────────────────────────────┐ +│ Status: Listening on port 9991... │ ← Status text +├─────────────────────────────────────────┤ +│ │ +│ [Port: 9991] │ ← Port input +│ │ +├─────────────────────────────────────────┤ +│ │ +│ │ +│ SurfaceView (Video) │ ← Video display +│ │ +│ │ +├─────────────────────────────────────────┤ +│ [Start Receiver Button] │ ← Control button +└─────────────────────────────────────────┘ +``` + +### Activity Features + +- ✅ SurfaceView for video rendering +- ✅ Port configuration (default: 9991) +- ✅ Start/Stop controls +- ✅ Status display with connection info +- ✅ Auto-detect device IP address +- ✅ Shows FFmpeg command for easy testing +- ✅ Proper lifecycle management (stops on destroy) + +## Testing Checklist + +### Pre-requisites +- [ ] libsrt built for Android (all ABIs) +- [ ] libsrt.so placed in `srt/src/main/jniLibs//` +- [ ] SRT headers in `srt/src/main/cpp/srt-include/srt/` +- [ ] Build project successfully +- [ ] Install app on Android device + +### Receiver Test (mode=listener) +- [ ] Launch app +- [ ] Select "SRT Receiver" from menu +- [ ] Enter port 9991 +- [ ] Click "Start Receiver" +- [ ] Note the IP address displayed +- [ ] From computer, run FFmpeg with caller mode +- [ ] Verify video displays on Android +- [ ] Verify audio plays on Android +- [ ] Check logcat for connection logs +- [ ] Stop receiver, verify clean shutdown + +### Expected Logs + +``` +I/SrtReceiver: Starting SRT receiver on port 9991 +I/SrtServerSocket: SRT server started on port 9991, fd=3 +I/SrtServerSocket: Waiting for client connection... +I/SrtServerSocket: Client connected, fd=4 +I/SrtReceiver: Client connected +I/TsDemuxer: PMT PID updated: 4096 +I/TsDemuxer: Video PID updated: 256 +I/TsDemuxer: Audio PID updated: 257 +I/H264Parser: SPS received, size=24 +I/H264Parser: PPS received, size=8 +I/SrtReceiver: H264 config ready (SPS/PPS) +I/VideoDecoder: Configuring video decoder: 1920x1080 +I/VideoDecoder: Video decoder started +I/AudioDecoder: Configuring audio decoder: sampleRate=48000, channels=2 +I/AudioDecoder: Audio decoder started +I/SrtServerSocket: Bitrate: 2500 kbps +``` + +## Troubleshooting + +### No connection +- Verify devices are on same network +- Check firewall allows port 9991 +- Confirm IP address is correct +- Ensure sender uses `mode=caller` + +### No video +- Check logs for "H264 config ready" +- Verify sender encodes H.264 +- Check SurfaceView is visible +- Verify SPS/PPS received + +### No audio +- Verify sender encodes AAC +- Check "Audio decoder started" in logs +- Test with different audio source + +### Build errors +- Verify libsrt.so exists for all ABIs +- Check CMakeLists.txt paths +- Ensure NDK is installed +- Check SRT headers are present + +## FFmpeg Test Commands + +### Send test pattern with video and audio: +```bash +ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 \ + -f lavfi -i sine=frequency=1000:sample_rate=48000 \ + -c:v libx264 -preset ultrafast -tune zerolatency \ + -c:a aac -b:a 128k \ + -f mpegts "srt://ANDROID_IP:9991?mode=caller" +``` + +### Send from webcam (Linux): +```bash +ffmpeg -f v4l2 -i /dev/video0 \ + -f alsa -i default \ + -c:v libx264 -preset ultrafast \ + -c:a aac -b:a 128k \ + -f mpegts "srt://ANDROID_IP:9991?mode=caller" +``` + +### Send existing video file: +```bash +ffmpeg -re -i input.mp4 -c copy \ + -f mpegts "srt://ANDROID_IP:9991?mode=caller" +``` + +## Code Location + +### New Demo Activity +- **Activity**: `app/src/main/java/com/pedro/streamer/srtreceiver/SrtReceiverActivity.kt` +- **Layout**: `app/src/main/res/layout/activity_srt_receiver.xml` +- **Manifest**: Updated `app/src/main/AndroidManifest.xml` +- **Menu**: Added to `MainActivity.kt` + +### SRT Receiver Implementation +- **Package**: `srt/src/main/java/com/pedro/srtreceiver/` +- **12 Kotlin files**: Main implementation +- **3 C++ files**: JNI wrapper for libsrt +- **Docs**: README.md, IMPLEMENTATION.md, SRT_RECEIVER_QUICKSTART.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 83802e0be7..d00d913d40 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,25 +1,29 @@ - + - - - - + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - + + + + + - - - + + + - + - + - + - + - - + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/MainActivity.kt b/app/src/main/java/com/pedro/streamer/MainActivity.kt index 7aae9a9467..edf89c7dd5 100644 --- a/app/src/main/java/com/pedro/streamer/MainActivity.kt +++ b/app/src/main/java/com/pedro/streamer/MainActivity.kt @@ -28,10 +28,12 @@ import android.widget.GridView import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat +import androidx.multidex.BuildConfig import com.pedro.streamer.file.FromFileActivity import com.pedro.streamer.oldapi.OldApiActivity import com.pedro.streamer.rotation.RotationActivity import com.pedro.streamer.screen.ScreenActivity +import com.pedro.streamer.streamvideoaudio.StreamVideoAudioActivity import com.pedro.streamer.utils.ActivityLink import com.pedro.streamer.utils.ImageAdapter import com.pedro.streamer.utils.fitAppPadding @@ -39,117 +41,129 @@ import com.pedro.streamer.utils.toast class MainActivity : AppCompatActivity() { - private lateinit var list: GridView - private val activities: MutableList = mutableListOf() + private lateinit var list: GridView + private val activities: MutableList = mutableListOf() - private val permissions = mutableListOf( - Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA, - ).apply { - if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { - this.add(Manifest.permission.POST_NOTIFICATIONS) + private val permissions = mutableListOf( + Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA, + ).apply { + if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { + this.add(Manifest.permission.POST_NOTIFICATIONS) + } + }.toTypedArray() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + fitAppPadding() + transitionAnim(true) + val tvVersion = findViewById(R.id.tv_version) + tvVersion.text = getString(R.string.version, BuildConfig.VERSION_NAME) + list = findViewById(R.id.list) + createList() + setListAdapter(activities) + requestPermissions() } - }.toTypedArray() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - fitAppPadding() - transitionAnim(true) - val tvVersion = findViewById(R.id.tv_version) - tvVersion.text = getString(R.string.version, BuildConfig.VERSION_NAME) - list = findViewById(R.id.list) - createList() - setListAdapter(activities) - requestPermissions() - } + @Suppress("DEPRECATION") + private fun transitionAnim(isOpen: Boolean) { + if (Build.VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { + val type = if (isOpen) OVERRIDE_TRANSITION_OPEN else OVERRIDE_TRANSITION_CLOSE + overrideActivityTransition(type, R.anim.slide_in, R.anim.slide_out) + } else { + overridePendingTransition(R.anim.slide_in, R.anim.slide_out) + } + } - @Suppress("DEPRECATION") - private fun transitionAnim(isOpen: Boolean) { - if (Build.VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { - val type = if (isOpen) OVERRIDE_TRANSITION_OPEN else OVERRIDE_TRANSITION_CLOSE - overrideActivityTransition(type, R.anim.slide_in, R.anim.slide_out) - } else { - overridePendingTransition(R.anim.slide_in, R.anim.slide_out) + private fun requestPermissions() { + if (!hasPermissions(this)) { + ActivityCompat.requestPermissions(this, permissions, 1) + } } - } - private fun requestPermissions() { - if (!hasPermissions(this)) { - ActivityCompat.requestPermissions(this, permissions, 1) + @SuppressLint("NewApi") + private fun createList() { + activities.add( + ActivityLink( + Intent(this, OldApiActivity::class.java), + getString(R.string.old_api), VERSION_CODES.JELLY_BEAN + ) + ) + activities.add( + ActivityLink( + Intent(this, FromFileActivity::class.java), + getString(R.string.from_file), VERSION_CODES.JELLY_BEAN_MR2 + ) + ) + activities.add( + ActivityLink( + Intent(this, ScreenActivity::class.java), + getString(R.string.display), VERSION_CODES.LOLLIPOP + ) + ) + activities.add( + ActivityLink( + Intent(this, RotationActivity::class.java), + getString(R.string.rotation_rtmp), VERSION_CODES.LOLLIPOP + ) + ) + activities.add( + ActivityLink( + Intent(this, com.pedro.streamer.srtreceiver.SrtReceiverActivity::class.java), + getString(R.string.srt_receiver), VERSION_CODES.N + ) + ) + activities.add( + ActivityLink( + Intent(this, StreamVideoAudioActivity::class.java), + getString(R.string.stream_with_video_and_audio), VERSION_CODES.LOLLIPOP + ) + ) } - } - @SuppressLint("NewApi") - private fun createList() { - activities.add( - ActivityLink( - Intent(this, OldApiActivity::class.java), - getString(R.string.old_api), VERSION_CODES.JELLY_BEAN - ) - ) - activities.add( - ActivityLink( - Intent(this, FromFileActivity::class.java), - getString(R.string.from_file), VERSION_CODES.JELLY_BEAN_MR2 - ) - ) - activities.add( - ActivityLink( - Intent(this, ScreenActivity::class.java), - getString(R.string.display), VERSION_CODES.LOLLIPOP - ) - ) - activities.add( - ActivityLink( - Intent(this, RotationActivity::class.java), - getString(R.string.rotation_rtmp), VERSION_CODES.LOLLIPOP - ) - ) - } + private fun setListAdapter(activities: List) { + list.adapter = ImageAdapter(activities) + list.onItemClickListener = + OnItemClickListener { _, _, position, _ -> + if (hasPermissions(this)) { + val link = activities[position] + val minSdk = link.minSdk + if (Build.VERSION.SDK_INT >= minSdk) { + startActivity(link.intent) + transitionAnim(false) + } else { + showMinSdkError(minSdk) + } + } else { + showPermissionsErrorAndRequest() + } + } + } - private fun setListAdapter(activities: List) { - list.adapter = ImageAdapter(activities) - list.onItemClickListener = - OnItemClickListener { _, _, position, _ -> - if (hasPermissions(this)) { - val link = activities[position] - val minSdk = link.minSdk - if (Build.VERSION.SDK_INT >= minSdk) { - startActivity(link.intent) - transitionAnim(false) - } else { - showMinSdkError(minSdk) - } - } else { - showPermissionsErrorAndRequest() + private fun showMinSdkError(minSdk: Int) { + val named: String = when (minSdk) { + VERSION_CODES.JELLY_BEAN_MR2 -> "JELLY_BEAN_MR2" + VERSION_CODES.LOLLIPOP -> "LOLLIPOP" + else -> "JELLY_BEAN" } - } - } - - private fun showMinSdkError(minSdk: Int) { - val named: String = when (minSdk) { - VERSION_CODES.JELLY_BEAN_MR2 -> "JELLY_BEAN_MR2" - VERSION_CODES.LOLLIPOP -> "LOLLIPOP" - else -> "JELLY_BEAN" + toast("You need min Android $named (API $minSdk)") } - toast("You need min Android $named (API $minSdk)") - } - private fun showPermissionsErrorAndRequest() { - toast("You need permissions before") - requestPermissions() - } + private fun showPermissionsErrorAndRequest() { + toast("You need permissions before") + requestPermissions() + } - private fun hasPermissions(context: Context): Boolean { - if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { - for (permission in permissions) { - if (ActivityCompat.checkSelfPermission(context, permission) - != PackageManager.PERMISSION_GRANTED - ) { - return false + private fun hasPermissions(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { + for (permission in permissions) { + if (ActivityCompat.checkSelfPermission(context, permission) + != PackageManager.PERMISSION_GRANTED + ) { + return false + } + } } - } + return true } - return true - } } \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt b/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt index 7d2446b168..dd0ace40da 100644 --- a/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt +++ b/app/src/main/java/com/pedro/streamer/rotation/CameraFragment.kt @@ -19,6 +19,7 @@ package com.pedro.streamer.rotation import android.annotation.SuppressLint import android.os.Build import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.SurfaceHolder import android.view.SurfaceView @@ -69,178 +70,195 @@ import java.util.Locale * [com.pedro.library.srt.SrtStream] */ @RequiresApi(Build.VERSION_CODES.LOLLIPOP) -class CameraFragment: Fragment(), ConnectChecker { +class CameraFragment : Fragment(), ConnectChecker { + val TAG = "CameraFragment" - companion object { - fun getInstance(): CameraFragment = CameraFragment() - } - - val genericStream: GenericStream by lazy { - GenericStream(requireContext(), this).apply { - getGlInterface().autoHandleOrientation = true + companion object { + fun getInstance(): CameraFragment = CameraFragment() } - } - private lateinit var surfaceView: SurfaceView - private lateinit var bStartStop: ImageView - private lateinit var txtBitrate: TextView - val width = 640 - val height = 480 - val vBitrate = 1200 * 1000 - private var rotation = 0 - private val sampleRate = 32000 - private val isStereo = true - private val aBitrate = 128 * 1000 - private var recordPath = "" - //Bitrate adapter used to change the bitrate on fly depend of the bandwidth. - private val bitrateAdapter = BitrateAdapter { - genericStream.setVideoBitrateOnFly(it) - }.apply { - setMaxBitrate(vBitrate + aBitrate) - } - - @SuppressLint("ClickableViewAccessibility") - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.fragment_camera, container, false) - bStartStop = view.findViewById(R.id.b_start_stop) - val bRecord = view.findViewById(R.id.b_record) - val bSwitchCamera = view.findViewById(R.id.switch_camera) - val etUrl = view.findViewById(R.id.et_rtp_url) - - txtBitrate = view.findViewById(R.id.txt_bitrate) - surfaceView = view.findViewById(R.id.surfaceView) - (activity as? RotationActivity)?.let { - surfaceView.setOnTouchListener(it) + + val genericStream: GenericStream by lazy { + GenericStream(requireContext(), this).apply { + getGlInterface().autoHandleOrientation = true + } } - surfaceView.holder.addCallback(object: SurfaceHolder.Callback { - override fun surfaceCreated(holder: SurfaceHolder) { - if (!genericStream.isOnPreview) genericStream.startPreview(surfaceView) - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - genericStream.getGlInterface().setPreviewResolution(width, height) - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - if (genericStream.isOnPreview) genericStream.stopPreview() - } - - }) - - bStartStop.setOnClickListener { - if (!genericStream.isStreaming) { - genericStream.startStream(etUrl.text.toString()) - bStartStop.setImageResource(R.drawable.stream_stop_icon) - } else { - genericStream.stopStream() - bStartStop.setImageResource(R.drawable.stream_icon) - } + private lateinit var surfaceView: SurfaceView + private lateinit var bStartStop: ImageView + private lateinit var txtBitrate: TextView + val width = 640 + val height = 480 + val vBitrate = 1200 * 1000 + private var rotation = 0 + private val sampleRate = 32000 + private val isStereo = true + private val aBitrate = 128 * 1000 + private var recordPath = "" + + //Bitrate adapter used to change the bitrate on fly depend of the bandwidth. + private val bitrateAdapter = BitrateAdapter { + genericStream.setVideoBitrateOnFly(it) + }.apply { + setMaxBitrate(vBitrate + aBitrate) } - bRecord.setOnClickListener { - if (!genericStream.isRecording) { - val folder = PathUtils.getRecordPath() - if (!folder.exists()) folder.mkdir() - val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) - recordPath = "${folder.absolutePath}/${sdf.format(Date())}.mp4" - bRecord.setImageResource(R.drawable.pause_icon) - genericStream.startRecord(recordPath) { status -> - if (status == RecordController.Status.RECORDING) { - onMainThreadHandler { - bRecord.setImageResource(R.drawable.stop_icon) + + @SuppressLint("ClickableViewAccessibility") + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_camera, container, false) + bStartStop = view.findViewById(R.id.b_start_stop) + val bRecord = view.findViewById(R.id.b_record) + val bSwitchCamera = view.findViewById(R.id.switch_camera) + val etUrl = view.findViewById(R.id.et_rtp_url) + + txtBitrate = view.findViewById(R.id.txt_bitrate) + surfaceView = view.findViewById(R.id.surfaceView) + (activity as? RotationActivity)?.let { + surfaceView.setOnTouchListener(it) + } + surfaceView.holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + if (!genericStream.isOnPreview) genericStream.startPreview(surfaceView) + } + + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int + ) { + genericStream.getGlInterface().setPreviewResolution(width, height) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + if (genericStream.isOnPreview) genericStream.stopPreview() + } + + }) + + bStartStop.setOnClickListener { + val message = "Link can't be empty," + + " The link must include the IPv4 address and port number in the format, for example: 1.1.1.1:9991" + val link = etUrl.text.trim().toString() + + if (link.isEmpty() || !link.contains(".") || !link.contains(":")) { + toast(message) + return@setOnClickListener + } + + if (genericStream.isStreaming) { + genericStream.stopStream() + bStartStop.setImageResource(R.drawable.stream_icon) + } else { + genericStream.startStream(link.plus("?mode=listener")) +// genericStream.startStream("srt://192.168.1.7:9991?mode=listener") + bStartStop.setImageResource(R.drawable.stream_stop_icon) + } + } + bRecord.setOnClickListener { + if (!genericStream.isRecording) { + val folder = PathUtils.getRecordPath() + if (!folder.exists()) folder.mkdir() + val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) + recordPath = "${folder.absolutePath}/${sdf.format(Date())}.mp4" + bRecord.setImageResource(R.drawable.pause_icon) + genericStream.startRecord(recordPath) { status -> + if (status == RecordController.Status.RECORDING) { + bRecord.setImageResource(R.drawable.stop_icon) + } + } + } else { + genericStream.stopRecord() + bRecord.setImageResource(R.drawable.record_icon) + PathUtils.updateGallery(requireContext(), recordPath) + } + } + bSwitchCamera.setOnClickListener { + when (val source = genericStream.videoSource) { + is Camera1Source -> source.switchCamera() + is Camera2Source -> source.switchCamera() + is CameraXSource -> source.switchCamera() } - } } - } else { - genericStream.stopRecord() - bRecord.setImageResource(R.drawable.record_icon) - PathUtils.updateGallery(requireContext(), recordPath) - } + return view + } + + fun setOrientationMode(isVertical: Boolean) { + val wasOnPreview = genericStream.isOnPreview + genericStream.release() + rotation = if (isVertical) 90 else 0 + prepare() + if (wasOnPreview) genericStream.startPreview(surfaceView) } - bSwitchCamera.setOnClickListener { - when (val source = genericStream.videoSource) { - is Camera1Source -> source.switchCamera() - is Camera2Source -> source.switchCamera() - is CameraXSource -> source.switchCamera() - } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + prepare() + genericStream.getStreamClient().setReTries(10) } - return view - } - - fun setOrientationMode(isVertical: Boolean) { - val wasOnPreview = genericStream.isOnPreview - genericStream.release() - rotation = if (isVertical) 90 else 0 - prepare() - if (wasOnPreview) genericStream.startPreview(surfaceView) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - prepare() - genericStream.getStreamClient().setReTries(10) - } - - private fun prepare() { - val prepared = try { - genericStream.prepareVideo(width, height, vBitrate, rotation = rotation) - && genericStream.prepareAudio(sampleRate, isStereo, aBitrate) - } catch (_: IllegalArgumentException) { - false + + private fun prepare() { + val prepared = try { + genericStream.prepareVideo(width, height, vBitrate, rotation = rotation) + && genericStream.prepareAudio(sampleRate, isStereo, aBitrate) + } catch (_: IllegalArgumentException) { + false + } + if (!prepared) { + toast("Audio or Video configuration failed") + activity?.finish() + } + } + + override fun onDestroy() { + super.onDestroy() + genericStream.release() + } + + override fun onConnectionStarted(url: String) { + } - if (!prepared) { - toast("Audio or Video configuration failed") - activity?.finish() + + override fun onConnectionSuccess() { + Log.d(TAG, "onConnection: Success") + toast("Connected") } - } - - override fun onDestroy() { - super.onDestroy() - genericStream.release() - } - - override fun onConnectionStarted(url: String) { - } - - override fun onConnectionSuccess() { - toast("Connected") - } - - override fun onConnectionFailed(reason: String) { - if (genericStream.getStreamClient().reTry(5000, reason, null)) { - toast("Retry") - } else { - genericStream.stopStream() - onMainThreadHandler { - bStartStop.setImageResource(R.drawable.stream_icon) - } - toast("Failed: $reason") + + override fun onConnectionFailed(reason: String) { + if (genericStream.getStreamClient().reTry(5000, reason, null)) { + toast("Retry") + } else { + genericStream.stopStream() + onMainThreadHandler { + bStartStop.setImageResource(R.drawable.stream_icon) + } + toast("Failed: $reason") + } } - } - override fun onNewBitrate(bitrate: Long) { - onMainThreadHandler { - bitrateAdapter.adaptBitrate(bitrate, genericStream.getStreamClient().hasCongestion()) - txtBitrate.text = String.format(Locale.getDefault(), "%.1f mb/s", bitrate / 1000_000f) + override fun onNewBitrate(bitrate: Long) { + onMainThreadHandler { + bitrateAdapter.adaptBitrate(bitrate, genericStream.getStreamClient().hasCongestion()) + txtBitrate.text = String.format(Locale.getDefault(), "%.1f mb/s", bitrate / 1000_000f) + } } - } - override fun onDisconnect() { - onMainThreadHandler { - txtBitrate.text = String() - toast("Disconnected") + override fun onDisconnect() { + onMainThreadHandler { + txtBitrate.text = String() + toast("Disconnected") + } } - } - override fun onAuthError() { - genericStream.stopStream() - onMainThreadHandler { - bStartStop.setImageResource(R.drawable.stream_icon) + override fun onAuthError() { + genericStream.stopStream() + onMainThreadHandler { + bStartStop.setImageResource(R.drawable.stream_icon) + } + toast("Auth error") } - toast("Auth error") - } - override fun onAuthSuccess() { - toast("Auth success") - } + override fun onAuthSuccess() { + toast("Auth success") + } } \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/srtreceiver/SrtReceiverActivity.kt b/app/src/main/java/com/pedro/streamer/srtreceiver/SrtReceiverActivity.kt new file mode 100644 index 0000000000..f5f259bc67 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/srtreceiver/SrtReceiverActivity.kt @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2024 pedroSG94. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.pedro.streamer.srtreceiver + +import android.os.Bundle +import android.view.SurfaceView +import android.view.WindowManager +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.pedro.srtreceiver.SrtReceiver +import com.pedro.streamer.R +import com.pedro.streamer.utils.fitAppPadding +import com.pedro.streamer.utils.toast + +/** + * Demo activity showing SRT receiver (listener mode) functionality. + * + * This activity demonstrates receiving an SRT stream in listener mode and displaying + * the video/audio on Android using MediaCodec decoders. + * + * Usage: + * 1. Start this activity + * 2. Enter port number (default: 9991) + * 3. Click "Start Receiver" + * 4. From another device/computer, send SRT stream: + * ffmpeg -re -i input.mp4 -c copy -f mpegts "srt://DEVICE_IP:9991?mode=caller" + */ +class SrtReceiverActivity : AppCompatActivity() { + + private lateinit var surfaceView: SurfaceView + private lateinit var etPort: EditText + private lateinit var bStartStop: Button + private lateinit var tvStatus: TextView + + private var srtReceiver: SrtReceiver? = null + private var isReceiving = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + setContentView(R.layout.activity_srt_receiver) + fitAppPadding() + + surfaceView = findViewById(R.id.surfaceView) + etPort = findViewById(R.id.et_port) + bStartStop = findViewById(R.id.b_start_stop) + tvStatus = findViewById(R.id.tv_status) + + // Default port + etPort.setText("9991") + + bStartStop.setOnClickListener { + if (isReceiving) { + stopReceiver() + } else { + startReceiver() + } + } + + updateStatus("Ready") + } + + private fun startReceiver() { + val portStr = etPort.text.toString() + if (portStr.isEmpty()) { + toast("Please enter a port number") + return + } + + val port = portStr.toIntOrNull() + if (port == null || port < 1024 || port > 65535) { + toast("Invalid port number (must be 1024-65535)") + return + } + + try { + // Create SRT receiver + srtReceiver = SrtReceiver(surfaceView) + + // Start listening + srtReceiver?.start(port) + + isReceiving = true + bStartStop.text = "Stop Receiver" + updateStatus("Listening on port $port\nWaiting for connection...") + + // Show connection info + val ipAddress = getIPAddress() + toast("Listening on $ipAddress:$port\nSend stream with:\nffmpeg -re -i input.mp4 -c copy -f mpegts \"srt://$ipAddress:$port?mode=caller\"") + + } catch (e: Exception) { + toast("Failed to start receiver: ${e.message}") + updateStatus("Error: ${e.message}") + e.printStackTrace() + } + } + + private fun stopReceiver() { + try { + srtReceiver?.stop() + srtReceiver = null + + isReceiving = false + bStartStop.text = "Start Receiver" + updateStatus("Stopped") + toast("SRT receiver stopped") + + } catch (e: Exception) { + toast("Error stopping receiver: ${e.message}") + e.printStackTrace() + } + } + + private fun updateStatus(status: String) { + runOnUiThread { + tvStatus.text = status + } + } + + private fun getIPAddress(): String { + try { + val interfaces = java.net.NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val networkInterface = interfaces.nextElement() + val addresses = networkInterface.inetAddresses + while (addresses.hasMoreElements()) { + val address = addresses.nextElement() + if (!address.isLoopbackAddress && address is java.net.Inet4Address) { + return address.hostAddress ?: "Unknown" + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return "Unknown" + } + + override fun onDestroy() { + super.onDestroy() + stopReceiver() + } +} diff --git a/app/src/main/java/com/pedro/streamer/streamvideoaudio/StreamVideoAudioActivity.kt b/app/src/main/java/com/pedro/streamer/streamvideoaudio/StreamVideoAudioActivity.kt new file mode 100644 index 0000000000..735a84ce55 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/streamvideoaudio/StreamVideoAudioActivity.kt @@ -0,0 +1,444 @@ +package com.pedro.streamer.streamvideoaudio + +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.OpenableColumns +import android.util.Log +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.widget.EditText +import android.widget.ImageView +import android.widget.SeekBar +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import com.pedro.common.ConnectChecker +import com.pedro.encoder.Frame +import com.pedro.encoder.input.audio.GetMicrophoneData +import com.pedro.encoder.input.sources.audio.AudioFileSource +import com.pedro.encoder.input.sources.audio.MicrophoneSource +import com.pedro.library.generic.GenericStream +import com.pedro.streamer.R +import com.pedro.streamer.utils.AudioMixer +import com.pedro.streamer.utils.fitAppPadding +import com.pedro.streamer.utils.toast + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class StreamVideoAudioActivity : AppCompatActivity(), ConnectChecker, SurfaceHolder.Callback { + + private val TAG = "StreamVideoAudio" + private val genericStream: GenericStream by lazy { + GenericStream(this, this).apply { + getGlInterface().autoHandleOrientation = true + } + } + private var audioFileSource: AudioFileSource? = null + private lateinit var btnStartStop: ImageView + private lateinit var btnPlayPauseAudio: ImageView + private lateinit var btnSelectAudio: ImageView + private lateinit var btnMicrophone: ImageView + private lateinit var txtTitleAudio: TextView + private lateinit var edtUrl: EditText + private lateinit var sbAudioVolume: SeekBar + private lateinit var sbMicVolume: SeekBar + private lateinit var surfaceView: SurfaceView + private var uriAudio: Uri? = null + private val audioMixer = AudioMixer() + private val AUDIO_SAMPLE_RATE = 44100 + private var currentAudioTime: Double = 0.0 + private var isMicMutedInternal = false + private val audioPicker = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + uri?.let { newUri -> + val fileRate = getSampleRate(newUri) + if (fileRate == -1) { + toast("Error: The selected file does not contain a valid audio track.") + return@let + } + if (fileRate != AUDIO_SAMPLE_RATE) { + toast("Error: This file is $fileRate Hz. Only 44100Hz is supported.") + return@let + } + + audioFileSource?.apply { + stop() + stopAudioDevice() + release() + } + + txtTitleAudio.text = getFileName(newUri) ?: "Audio selected" + currentAudioTime = 0.0 + this.uriAudio = newUri + try { + audioFileSource = createAudioFileSource(newUri) + } catch (ex: Exception) { + toast(ex.message ?: "Error loading file audio") + } + if (genericStream.isStreaming) { + toggleAudio() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_stream_video_audio) + fitAppPadding() + initViews() + prepareStream() + } + + private fun initViews() { + btnStartStop = findViewById(R.id.btn_start_stop) + btnPlayPauseAudio = findViewById(R.id.btn_play_pause) + btnSelectAudio = findViewById(R.id.btn_select_audio) + btnMicrophone = findViewById(R.id.btn_microphone) + txtTitleAudio = findViewById(R.id.txt_audio_title) + edtUrl = findViewById(R.id.edt_url) + surfaceView = findViewById(R.id.surfaceView) + sbAudioVolume = findViewById(R.id.sb_volume_audio) + sbMicVolume = findViewById(R.id.sb_volume_mic) + surfaceView.holder.addCallback(this) + + val isMicMuted = (genericStream.audioSource as? MicrophoneSource)?.isMuted() ?: false + stateMicButton(!isMicMuted) + stateAudioButton(false) + btnPlayPauseAudio.setImageResource(R.drawable.play_video_48) + + btnSelectAudio.setOnClickListener { + audioPicker.launch(arrayOf("audio/*", "video/*")) + } + + btnStartStop.setOnClickListener { + toggleStream() + } + + btnPlayPauseAudio.setOnClickListener { + toggleAudio() + } + + btnMicrophone.setOnClickListener { + toggleMicrophone() + } + + sbAudioVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + val audioFileSource = audioFileSource ?: return + val audioTrack = audioFileSource.getAudioTrack() ?: return + val volume = progress / 100f + + audioMixer.audioVolume = volume + audioTrack.setVolume(volume) + Log.d(TAG, "Volume changed to: $volume") + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + + sbMicVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (!isMicMutedInternal) { + val volume = progress / 100f + audioMixer.micVolume = volume + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + + } + + private fun getFileName(uri: Uri): String? { + var name: String? = null + val cursor = contentResolver.query(uri, null, null, null, null) + cursor?.use { + val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + + if (it.moveToFirst()) { + name = it.getString(index) + } + } + return name + } + + private fun toggleMicrophone() { + if (!genericStream.isStreaming) { + return + } + + isMicMutedInternal = !isMicMutedInternal + + if (isMicMutedInternal) { + // TURN OFF MIC: Set the volume to 0 on the mixer. + audioMixer.micVolume = 0f + stateMicButton(false) + toast("Microphone OFF") + } else { + // TURN ON THE MIC: Reclaiming Value from SeekBar + audioMixer.micVolume = sbMicVolume.progress / 100f + stateMicButton(true) + toast("Microphone ON") + } + + val micSource = genericStream.audioSource as? MicrophoneSource + micSource?.unMute() + + } + + private fun prepareStream() { + try { + val micSource = genericStream.audioSource as? MicrophoneSource + micSource?.setAudioEffect(audioMixer) + + val isPrepareAudio = genericStream.prepareAudio( + sampleRate = AUDIO_SAMPLE_RATE, + isStereo = true, + bitrate = 128 * 1000 + ) + val isPrepareVideo = + genericStream.prepareVideo(width = 640, height = 480, bitrate = 1200 * 1000) + genericStream.getStreamClient().setReTries(10) + val isCheck = isPrepareAudio && isPrepareVideo + if (!isCheck) { + toast("Audio or Video configuration failed") + return + } + + } catch (ex: IllegalStateException) { + toast(ex.message ?: "Prepare stream failed") + return + } catch (ex: Exception) { + toast(ex.message ?: "Prepare stream failed") + return + } + } + + private fun stateMicButton(isEnable: Boolean = false) { + runOnUiThread { + val drawable = + if (isEnable) R.drawable.microphone_icon else R.drawable.microphone_off_icon + btnMicrophone.setImageResource(drawable) + } + } + + private fun stateStream(isStream: Boolean = false) { + runOnUiThread { + if (isStream) { + btnStartStop.setImageResource(R.drawable.stream_stop_icon) + } else { + btnStartStop.setImageResource(R.drawable.stream_icon) + } + } + } + + private fun toggleStream() { + if (genericStream.isStreaming) { + runOnUiThread { + audioFileSource?.apply { + stop() + stopAudioDevice() + release() + } + stateStream(false) + stateAudioButton(false) + btnPlayPauseAudio.setImageResource(R.drawable.play_video_48) + currentAudioTime = 0.0 + genericStream.stopStream() + } + return + } + + val url = edtUrl.text.toString().trim() +// val url = "srt://192.168.1.5:9991?mode=listener" + if (url.isEmpty()) { + toast("Please enter a valid URL") + return + } + + genericStream.startStream(url) + stateStream(true) + stateAudioButton(true) + } + + private fun stateAudioButton(isEnable: Boolean = false) { + btnPlayPauseAudio.apply { + isEnabled = isEnable + alpha = if (isEnable) 1f else 0.5f + } + } + + fun getSampleRate(uri: Uri): Int { + val extractor = MediaExtractor() + try { + extractor.setDataSource(this, uri, null) + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val mime = format.getString(MediaFormat.KEY_MIME) + if (mime?.startsWith("audio/") == true) { + val rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + return rate + } + } + } catch (e: Exception) { + Log.e(TAG, "Error extracting sample rate from $uri", e) + return -1 + } finally { + extractor.release() + } + return -1 + } + + private fun createAudioFileSource(uri: Uri): AudioFileSource? { + try { + val fileRate = getSampleRate(uri) + + if (fileRate == -1) { + throw IllegalArgumentException("The selected file does not contain a valid audio track.") + } + if (fileRate != AUDIO_SAMPLE_RATE) { + throw IllegalArgumentException("Only 44100Hz files are supported.") + } + + return AudioFileSource(this, uri, false) { isLoop -> + if (isLoop) { + runOnUiThread { + toast("Audio looped") + } + } else { + // The Audio ends completely (if loopMode = false) + audioFileSource?.stop() + audioFileSource?.stopAudioDevice() + runOnUiThread { + currentAudioTime = 0.0 + btnPlayPauseAudio.setImageResource(R.drawable.play_video_48) + audioMixer.clear() + toast("Audio finished") + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error loading file audio", e) + toast("Error loading file audio: ${e.message}") + return null + } + } + + private fun toggleAudio() { + val uri = uriAudio ?: return toast("Please select a audio file") + + if (audioFileSource == null) { + try { + audioFileSource = createAudioFileSource(uri) + } catch (ex: Exception) { + toast(ex.message ?: "Error loading file audio") + } + } + + val source = audioFileSource ?: return toast("Error loading audio file") + + try { + // audio is running + if (source.isRunning()) { + currentAudioTime = source.getTime() + source.stop() + source.stopAudioDevice() + audioMixer.clear() + btnPlayPauseAudio.setImageResource(R.drawable.play_video_48) // Play icon + toast("Audio pause") + return + } + // audio is not running + val isInitialized = source.init( + AUDIO_SAMPLE_RATE, + isStereo = true, + echoCanceler = false, + noiseSuppressor = false + ) + if (!isInitialized) return toast("Error loading audio file") + source.start(object : GetMicrophoneData { + override fun inputPCMData(frame: Frame) { +// val pcm = frame.buffer.copyOfRange(frame.offset, frame.offset + frame.size) + val pcm = ByteArray(frame.size) + System.arraycopy(frame.buffer, frame.offset, pcm, 0, frame.size) + audioMixer.pushAudioData(pcm) + } + }) + source.playAudioDevice() + val duration = source.getDuration() + if (currentAudioTime >= duration) { + currentAudioTime = 0.0 + } + source.moveTo(currentAudioTime) + + //check if the audio is actually playing. + if (source.isAudioDeviceEnabled()) { + toast("audio playing") + btnPlayPauseAudio.setImageResource(R.drawable.pause_icon) // Pause icon + } else { + btnPlayPauseAudio.setImageResource(R.drawable.play_video_48) + toast("Error playing audio") + } + + } catch (e: Exception) { + Log.e(TAG, "Error playing audio", e) + toast("Error playing audio: ${e.message}") + return + } + } + + // --- Surface Callback --- + override fun surfaceCreated(holder: SurfaceHolder) { + if (!genericStream.isOnPreview) genericStream.startPreview(surfaceView) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, w: Int, h: Int) { + genericStream.getGlInterface().setPreviewResolution(w, h) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + if (genericStream.isOnPreview) genericStream.stopPreview() + } + + // --- ConnectChecker Callbacks --- + override fun onConnectionSuccess() { + toast("Connected") + val micSource = genericStream.audioSource as? MicrophoneSource + micSource?.setAudioEffect(audioMixer) + } + + override fun onConnectionFailed(reason: String) { + if (genericStream.getStreamClient().reTry(5000, reason, null)) { + Log.d(TAG, "onConnection: Retry") + } else { + genericStream.stopStream() + stateStream(false) + Log.d(TAG, "onConnection: Failed : $reason") + toast("Connection : Failed") + } + } + + override fun onConnectionStarted(url: String) {} + override fun onDisconnect() { + toast("Disconnected") + } + + override fun onAuthError() {} + override fun onAuthSuccess() {} + + override fun onDestroy() { + super.onDestroy() + genericStream.release() + uriAudio = null + audioFileSource?.apply { + stop() + stopAudioDevice() + release() + } + } +} diff --git a/app/src/main/java/com/pedro/streamer/utils/AudioMixer.kt b/app/src/main/java/com/pedro/streamer/utils/AudioMixer.kt new file mode 100644 index 0000000000..b742ad9211 --- /dev/null +++ b/app/src/main/java/com/pedro/streamer/utils/AudioMixer.kt @@ -0,0 +1,57 @@ +package com.pedro.streamer.utils + +import com.pedro.encoder.input.audio.AudioUtils +import com.pedro.encoder.input.audio.CustomAudioEffect +import java.io.ByteArrayOutputStream + +class AudioMixer : CustomAudioEffect() { + private val audioUtils = AudioUtils() + private val audioBufferStream = ByteArrayOutputStream() + var audioVolume = 1.0f + var micVolume = 1.0f + + @Synchronized + override fun process(pcmBuffer: ByteArray): ByteArray { + val audioData = audioBufferStream.toByteArray() + + // If the microphone is muted, pcmBuffer will show only zeros. + // We still generate results based on the size of the microphone. + val mixedResult = ByteArray(pcmBuffer.size) + + if (audioData.size >= pcmBuffer.size) { + // Extract Audio data + val toMixAudio = audioData.copyOfRange(0, pcmBuffer.size) + + // MIXING: Use AudioUtils to blend Mic and Audio + // MicVolume and MusicVolume will determine which one is louder. + audioUtils.applyVolumeAndMix( + buffer = pcmBuffer, volume = micVolume, + buffer2 = toMixAudio, volume2 = audioVolume, + dst = mixedResult + ) + + //Remove used music from library. + val remaining = audioData.copyOfRange(pcmBuffer.size, audioData.size) + audioBufferStream.reset() + audioBufferStream.write(remaining) + + return mixedResult + } else { + // If there is no music, return the microphone sound (with mic volume applied). + audioUtils.applyVolume(pcmBuffer, micVolume) + return pcmBuffer + } + } + + @Synchronized + fun pushAudioData(data: ByteArray) { + // Limit the cache memory to avoid expensive RAM usage. + if (audioBufferStream.size() > 128000) return + audioBufferStream.write(data) + } + + @Synchronized + fun clear() { + audioBufferStream.reset() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pedro/streamer/utils/Extensions.kt b/app/src/main/java/com/pedro/streamer/utils/Extensions.kt index 36e8aca1a2..9fa808184e 100644 --- a/app/src/main/java/com/pedro/streamer/utils/Extensions.kt +++ b/app/src/main/java/com/pedro/streamer/utils/Extensions.kt @@ -44,49 +44,61 @@ import com.pedro.streamer.R */ fun Activity.toast(message: String, duration: Int = Toast.LENGTH_SHORT) { - Toast.makeText(this, message, duration).show() + Toast.makeText(this, message, duration).show() } fun Fragment.toast(message: String, duration: Int = Toast.LENGTH_SHORT) { - Toast.makeText(requireContext(), message, duration).show() + context?.let { + Toast.makeText(it, message, duration).show() + } } fun Service.toast(message: String, duration: Int = Toast.LENGTH_SHORT) { - Toast.makeText(this, message, duration).show() + Toast.makeText(this, message, duration).show() } fun Context.toast(message: String, duration: Int = Toast.LENGTH_SHORT) { - Toast.makeText(this, message, duration).show() + Toast.makeText(this, message, duration).show() } fun MenuItem.setColor(context: Context, @ColorRes color: Int) { - val spannableString = SpannableString(title.toString()) - spannableString.setSpan(ForegroundColorSpan(ContextCompat.getColor(context, color)), 0, spannableString.length, 0) - title = spannableString + val spannableString = SpannableString(title.toString()) + spannableString.setSpan( + ForegroundColorSpan(ContextCompat.getColor(context, color)), + 0, + spannableString.length, + 0 + ) + title = spannableString } @Suppress("DEPRECATION") fun Drawable.setColorFilter(@ColorInt color: Int) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) - } else { - setColorFilter(color, PorterDuff.Mode.SRC_IN) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + colorFilter = BlendModeColorFilter(color, BlendMode.SRC_IN) + } else { + setColorFilter(color, PorterDuff.Mode.SRC_IN) + } } fun MenuItem.updateMenuColor(context: Context, currentItem: MenuItem?): MenuItem { - currentItem?.setColor(context, R.color.black) - setColor(context, R.color.appColor) - return this + currentItem?.setColor(context, R.color.black) + setColor(context, R.color.appColor) + return this } fun AppCompatActivity.fitAppPadding() { - ViewCompat.setOnApplyWindowInsetsListener(window.decorView.rootView) { view, insets -> - val bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - ) - view.updatePadding(left = bars.left, top = bars.top, right = bars.right, bottom = bars.bottom) - view.setBackgroundColor(ContextCompat.getColor(this, R.color.appColor)) - WindowInsetsCompat.CONSUMED - } + ViewCompat.setOnApplyWindowInsetsListener(window.decorView.rootView) { view, insets -> + val bars = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.updatePadding( + left = bars.left, + top = bars.top, + right = bars.right, + bottom = bars.bottom + ) + view.setBackgroundColor(ContextCompat.getColor(this, R.color.appColor)) + WindowInsetsCompat.CONSUMED + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_audio_file_48.xml b/app/src/main/res/drawable/baseline_audio_file_48.xml new file mode 100644 index 0000000000..48b7400fcf --- /dev/null +++ b/app/src/main/res/drawable/baseline_audio_file_48.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/play_video_48.png b/app/src/main/res/drawable/play_video_48.png new file mode 100644 index 0000000000..ad58bc6ecf Binary files /dev/null and b/app/src/main/res/drawable/play_video_48.png differ diff --git a/app/src/main/res/layout/activity_srt_receiver.xml b/app/src/main/res/layout/activity_srt_receiver.xml new file mode 100644 index 0000000000..e12fae0f44 --- /dev/null +++ b/app/src/main/res/layout/activity_srt_receiver.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + +