From ec4f9ee64129cbc5d5b25aa7f749edf9acd9ae52 Mon Sep 17 00:00:00 2001 From: xufulong <839789740@qq.com> Date: Tue, 20 Oct 2020 01:08:39 +0800 Subject: [PATCH] Audio effect: equalizer and visualizer Audio effect: equalizer and visualizer --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 47 ++-- .../ffmpeg/activity/AudioEffectActivity.kt | 255 ++++++++++++++++++ .../frank/ffmpeg/activity/MainActivity.java | 6 +- .../frank/ffmpeg/adapter/EqualizerAdapter.kt | 89 ++++++ .../frank/ffmpeg/format/AudioVisualizer.java | 35 +++ .../frank/ffmpeg/listener/OnSeeBarListener.kt | 5 + .../com/frank/ffmpeg/view/VisualizerView.kt | 168 ++++++++++++ .../main/res/layout/activity_audio_effect.xml | 80 ++++++ app/src/main/res/layout/activity_main.xml | 7 + app/src/main/res/layout/adapter_equalizer.xml | 18 ++ app/src/main/res/values-en/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 13 files changed, 697 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/frank/ffmpeg/activity/AudioEffectActivity.kt create mode 100644 app/src/main/java/com/frank/ffmpeg/adapter/EqualizerAdapter.kt create mode 100644 app/src/main/java/com/frank/ffmpeg/format/AudioVisualizer.java create mode 100644 app/src/main/java/com/frank/ffmpeg/listener/OnSeeBarListener.kt create mode 100644 app/src/main/java/com/frank/ffmpeg/view/VisualizerView.kt create mode 100644 app/src/main/res/layout/activity_audio_effect.xml create mode 100644 app/src/main/res/layout/adapter_equalizer.xml diff --git a/app/build.gradle b/app/build.gradle index 0ac633a..72e4555 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -56,6 +56,7 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation "androidx.appcompat:appcompat:$rootProject.appcompatVersion" implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerviewVersion" + implementation 'androidx.appcompat:appcompat:1.2.0' testImplementation 'junit:junit:4.12' implementation project(':Live') implementation "androidx.core:core-ktx:$rootProject.core_ktx" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 90fb5f2..ea88d15 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,57 +8,54 @@ - + + android:theme="@style/AppTheme" + tools:replace="android:name"> - + - + + - - - - - - - - + + + + - - - + android:screenOrientation="landscape" /> + - + android:screenOrientation="portrait" /> - - + + - - diff --git a/app/src/main/java/com/frank/ffmpeg/activity/AudioEffectActivity.kt b/app/src/main/java/com/frank/ffmpeg/activity/AudioEffectActivity.kt new file mode 100644 index 0000000..7e22894 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/activity/AudioEffectActivity.kt @@ -0,0 +1,255 @@ +package com.frank.ffmpeg.activity + +import android.Manifest +import android.media.MediaPlayer +import android.media.audiofx.* +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.os.Environment +import android.util.Log +import android.util.Pair +import android.view.View +import android.widget.* +import androidx.annotation.RequiresApi +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.frank.ffmpeg.R +import com.frank.ffmpeg.adapter.EqualizerAdapter +import com.frank.ffmpeg.format.AudioVisualizer +import com.frank.ffmpeg.listener.OnSeeBarListener +import com.frank.ffmpeg.view.VisualizerView +import java.io.IOException +import java.util.ArrayList + +/** + * Audio effect: equalizer, enhancer, visualizer + * Created by frank on 2020/10/20. + */ +class AudioEffectActivity : AppCompatActivity(), OnSeeBarListener { + + companion object { + private val TAG = AudioEffectActivity::class.java.simpleName + + private val AUDIO_PATH = Environment.getExternalStorageDirectory().path + "/heart.mp3" + } + + private var mPlayer: MediaPlayer? = null + private var mEqualizer: Equalizer? = null + private var mBass: BassBoost? = null + private var mPresetReverb: PresetReverb? = null + private val reverbValues = ArrayList() + private var seekBarList: List? = ArrayList() + private var bands: Short = 0 + private var minEQLevel: Short = 0 + private var spinnerStyle: Spinner? = null + private var spinnerReverb: Spinner? = null + private var barBassBoost: SeekBar? = null + private var equalizerAdapter: EqualizerAdapter? = null + private val enableEqualizer = true + private var loudnessEnhancer: LoudnessEnhancer? = null + private var barEnhancer: SeekBar? = null + private var visualizerView: VisualizerView? = null + private var mVisualizer: AudioVisualizer? = null + + private val presetReverb = arrayOf("None", "SmallRoom", "MediumRoom", "LargeRoom", + "MediumHall", "LargeHall", "Plate") + + private val permissions = arrayOf( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MODIFY_AUDIO_SETTINGS) + + private val onPreparedListener = MediaPlayer.OnPreparedListener { + setupEqualizer() + setupPresetStyle() + setupReverberation() + setupBassBoost() + setLoudnessEnhancer() + setupVisualizer() + + mPlayer!!.start() + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + checkPermission() + setContentView(R.layout.activity_audio_effect) + initView() + initPlayer() + } + + private fun initView() { + spinnerStyle = findViewById(R.id.spinner_style) + spinnerReverb = findViewById(R.id.spinner_reverb) + barBassBoost = findViewById(R.id.bar_bassboost) + barEnhancer = findViewById(R.id.bar_enhancer) + visualizerView = findViewById(R.id.visualizer_view) + val equalizerView = findViewById(R.id.equalizer_view) + val layoutManager = LinearLayoutManager(this) + layoutManager.orientation = RecyclerView.VERTICAL + equalizerView.layoutManager = layoutManager + equalizerAdapter = EqualizerAdapter(this, this) + equalizerView.adapter = equalizerAdapter + } + + private fun checkPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestPermissions(permissions, 456) + } + } + + private fun initPlayer() { + try { + mPlayer = MediaPlayer() + mPlayer!!.setDataSource(AUDIO_PATH) + mPlayer!!.setOnPreparedListener(onPreparedListener) + mPlayer!!.prepareAsync() + } catch (e: IOException) { + e.printStackTrace() + } + } + + override fun onProgress(index: Int, progress: Int) { + mEqualizer!!.setBandLevel(index.toShort(), (progress + minEQLevel).toShort()) + } + + private fun setupEqualizer() { + val equalizerList = ArrayList>() + mEqualizer = Equalizer(0, mPlayer!!.audioSessionId) + mEqualizer!!.enabled = enableEqualizer + // band level: min and max + minEQLevel = mEqualizer!!.bandLevelRange[0]//min level + val maxEQLevel = mEqualizer!!.bandLevelRange[1] // max level + bands = mEqualizer!!.numberOfBands + for (i in 0 until bands) { + val centerFreq = (mEqualizer!!.getCenterFreq(i.toShort()) / 1000).toString() + " Hz" + val pair = Pair.create(centerFreq, mEqualizer!!.getBandLevel(i.toShort()) - minEQLevel) + equalizerList.add(pair) + } + if (equalizerAdapter != null) { + equalizerAdapter!!.setMaxProgress(maxEQLevel - minEQLevel) + equalizerAdapter!!.setEqualizerList(equalizerList) + } + } + + private fun setupPresetStyle() { + for (i in 0 until mEqualizer!!.numberOfPresets) { + reverbValues.add(mEqualizer!!.getPresetName(i.toShort())) + } + + spinnerStyle!!.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, reverbValues) + spinnerStyle!!.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(arg0: AdapterView<*>, arg1: View, arg2: Int, arg3: Long) { + try { + mEqualizer!!.usePreset(arg2.toShort()) + if (equalizerAdapter != null) { + seekBarList = equalizerAdapter!!.getSeekBarList() + } + if (bands > 0 && seekBarList != null && mEqualizer != null) { + for (band in 0 until bands) { + seekBarList!![band].progress = mEqualizer!!.getBandLevel(band.toShort()) - minEQLevel + } + } + + } catch (e: Exception) { + Log.e(TAG, "preset style error=$e") + } + + } + override fun onNothingSelected(arg0: AdapterView<*>) {} + } + } + + private fun setupReverberation() { + mPresetReverb = PresetReverb(0, mPlayer!!.audioSessionId) + mPresetReverb!!.enabled = enableEqualizer + mPlayer!!.attachAuxEffect(mPresetReverb!!.id) + //sendLevel:0-1 + mPlayer!!.setAuxEffectSendLevel(1.0f) + + spinnerReverb!!.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, presetReverb) + spinnerReverb!!.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(arg0: AdapterView<*>, arg1: View, arg2: Int, arg3: Long) { + try { + mPresetReverb!!.preset = arg2.toShort() + } catch (e: Exception) { + Log.e(TAG, "preset reverberation error=$e") + } + } + override fun onNothingSelected(arg0: AdapterView<*>) {} + } + } + + private fun setupBassBoost() { + mBass = BassBoost(0, mPlayer!!.audioSessionId) + mBass!!.enabled = enableEqualizer + // 0--1000 + barBassBoost!!.max = 1000 + barBassBoost!!.progress = 0 + barBassBoost!!.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + // set the strength of bass boost + mBass!!.setStrength(progress.toShort()) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + } + + @RequiresApi(Build.VERSION_CODES.KITKAT) + private fun setLoudnessEnhancer() { + loudnessEnhancer = LoudnessEnhancer(mPlayer!!.audioSessionId) + loudnessEnhancer!!.enabled = true + // Unit: mB + loudnessEnhancer!!.setTargetGain(500) + barEnhancer!!.max = 1000 + barEnhancer!!.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + loudnessEnhancer!!.setTargetGain(progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + } + }) + } + + + private fun setupVisualizer() { + mVisualizer = AudioVisualizer() + mVisualizer?.initVisualizer(mPlayer!!.audioSessionId, false, true, object: Visualizer.OnDataCaptureListener{ + override fun onFftDataCapture(visualizer: Visualizer?, fft: ByteArray?, samplingRate: Int) { + if (visualizerView != null && fft != null) { + visualizerView!!.post { visualizerView!!.setWaveData(fft) } + } + } + + override fun onWaveFormDataCapture(visualizer: Visualizer?, waveform: ByteArray?, samplingRate: Int) { + } + }) + } + + private fun releaseVisualizer() { + mVisualizer?.releaseVisualizer() + } + + override fun onDestroy() { + super.onDestroy() + + mEqualizer!!.release() + mPresetReverb!!.release() + mBass!!.release() + loudnessEnhancer!!.release() + releaseVisualizer() + mPlayer!!.release() + } + +} diff --git a/app/src/main/java/com/frank/ffmpeg/activity/MainActivity.java b/app/src/main/java/com/frank/ffmpeg/activity/MainActivity.java index 96a7b60..5727248 100644 --- a/app/src/main/java/com/frank/ffmpeg/activity/MainActivity.java +++ b/app/src/main/java/com/frank/ffmpeg/activity/MainActivity.java @@ -30,7 +30,8 @@ public class MainActivity extends BaseActivity { R.id.btn_live, R.id.btn_filter, R.id.btn_preview, - R.id.btn_probe + R.id.btn_probe, + R.id.btn_audio_effect ); } @@ -65,6 +66,9 @@ public class MainActivity extends BaseActivity { case R.id.btn_probe://probe media format intent.setClass(MainActivity.this, ProbeFormatActivity.class); break; + case R.id.btn_audio_effect://audio effect + intent.setClass(MainActivity.this, AudioEffectActivity.class); + break; default: break; } diff --git a/app/src/main/java/com/frank/ffmpeg/adapter/EqualizerAdapter.kt b/app/src/main/java/com/frank/ffmpeg/adapter/EqualizerAdapter.kt new file mode 100644 index 0000000..48ee247 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/adapter/EqualizerAdapter.kt @@ -0,0 +1,89 @@ +package com.frank.ffmpeg.adapter + +import android.content.Context +import android.util.Pair +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +import com.frank.ffmpeg.R +import com.frank.ffmpeg.listener.OnSeeBarListener + +import java.util.ArrayList + +/** + * @author frank + * @date 2020-10-19 0:19 + * @desc Adapter of equalizer + */ + +class EqualizerAdapter(private val context: Context, private val onSeeBarListener: OnSeeBarListener?) : RecyclerView.Adapter() { + + private var equalizerList: List>? = ArrayList() + private val seekBarList = ArrayList() + private var maxProgress: Int = 0 + + override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): RecyclerView.ViewHolder { + return EqualizerHolder(LayoutInflater.from(context).inflate(R.layout.adapter_equalizer, null)) + } + + override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, i: Int) { + val holder = viewHolder as EqualizerHolder + if (equalizerList != null) { + val centerFreq = equalizerList!![i].first as String + holder.txtFrequency.text = centerFreq + } + seekBarList.add(holder.barEqualizer) + holder.barEqualizer.max = maxProgress + val currentProgress = equalizerList!![i].second as Int + holder.barEqualizer.progress = currentProgress + + holder.barEqualizer.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + onSeeBarListener?.onProgress(i, progress) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + + } + }) + } + + override fun getItemCount(): Int { + return if (equalizerList != null) equalizerList!!.size else 0 + } + + private inner class EqualizerHolder internal constructor(itemView: View) : RecyclerView.ViewHolder(itemView) { + + val txtFrequency: TextView = itemView.findViewById(R.id.txt_frequency) + val barEqualizer: SeekBar = itemView.findViewById(R.id.bar_equalizer) + + } + + fun setMaxProgress(maxProgress: Int) { + if (maxProgress > 0) { + this.maxProgress = maxProgress + } + } + + fun setEqualizerList(equalizerList: List>?) { + if (equalizerList != null) { + this.equalizerList = equalizerList + notifyDataSetChanged() + } + } + + fun getSeekBarList(): List { + return seekBarList + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/frank/ffmpeg/format/AudioVisualizer.java b/app/src/main/java/com/frank/ffmpeg/format/AudioVisualizer.java new file mode 100644 index 0000000..7e40bdc --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/format/AudioVisualizer.java @@ -0,0 +1,35 @@ +package com.frank.ffmpeg.format; + +import android.media.audiofx.Visualizer; +import android.os.Build; + +/** + * Visualizer of Audio frequency + * Created by frank on 2020/10/20. + */ +public class AudioVisualizer { + + private Visualizer visualizer; + + public void initVisualizer(int audioSession, boolean waveform, boolean fft, Visualizer.OnDataCaptureListener dataCaptureListener) { + visualizer = new Visualizer(audioSession); + int captureSize = Visualizer.getCaptureSizeRange()[1]; + int captureRate = Visualizer.getMaxCaptureRate() / 2; + + visualizer.setCaptureSize(captureSize); + visualizer.setDataCaptureListener(dataCaptureListener, captureRate, waveform, fft); + visualizer.setScalingMode(Visualizer.SCALING_MODE_NORMALIZED); + visualizer.setEnabled(true); + } + + public void releaseVisualizer() { + if (visualizer != null) { + int captureRate = Visualizer.getMaxCaptureRate() / 2; + visualizer.setEnabled(false); + visualizer.setDataCaptureListener(null, captureRate, false, false); + visualizer.release(); + visualizer = null; + } + } + +} diff --git a/app/src/main/java/com/frank/ffmpeg/listener/OnSeeBarListener.kt b/app/src/main/java/com/frank/ffmpeg/listener/OnSeeBarListener.kt new file mode 100644 index 0000000..73fa736 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/listener/OnSeeBarListener.kt @@ -0,0 +1,5 @@ +package com.frank.ffmpeg.listener + +interface OnSeeBarListener { + fun onProgress(index: Int, progress: Int) +} \ No newline at end of file diff --git a/app/src/main/java/com/frank/ffmpeg/view/VisualizerView.kt b/app/src/main/java/com/frank/ffmpeg/view/VisualizerView.kt new file mode 100644 index 0000000..73f76a0 --- /dev/null +++ b/app/src/main/java/com/frank/ffmpeg/view/VisualizerView.kt @@ -0,0 +1,168 @@ +package com.frank.ffmpeg.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Point +import android.util.AttributeSet +import android.view.View + +import java.util.ArrayList + +/** + * the custom view of audio visualizer + * Created by frank on 2020/10/19. + */ +class VisualizerView : View { + + private var upShowStyle = ShowStyle.STYLE_HOLLOW_LUMP + + private var waveData: ByteArray? = null + private var pointList: MutableList? = null + + private var lumpPaint: Paint? = null + private var wavePath = Path() + + + constructor(context: Context) : super(context) { + init() + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + init() + } + + private fun init() { + lumpPaint = Paint() + lumpPaint!!.isAntiAlias = true + lumpPaint!!.color = LUMP_COLOR + + lumpPaint!!.strokeWidth = 2f + lumpPaint!!.style = Paint.Style.STROKE + } + + fun setWaveData(data: ByteArray) { + this.waveData = readyData(data) + genSamplingPoint(data) + invalidate() + } + + + fun setStyle(upShowStyle: ShowStyle) { + this.upShowStyle = upShowStyle + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + wavePath.reset() + + for (i in 0 until LUMP_COUNT) { + if (waveData == null) { + canvas.drawRect(((LUMP_WIDTH + LUMP_SPACE) * i).toFloat(), + (LUMP_MAX_HEIGHT - LUMP_MIN_HEIGHT).toFloat(), + ((LUMP_WIDTH + LUMP_SPACE) * i + LUMP_WIDTH).toFloat(), + LUMP_MAX_HEIGHT.toFloat(), + lumpPaint!!) + continue + } + + when (upShowStyle) { + ShowStyle.STYLE_HOLLOW_LUMP -> drawLump(canvas, i, false) + ShowStyle.STYLE_WAVE -> drawWave(canvas, i, false) + else -> { + } + } + } + } + + private fun drawWave(canvas: Canvas, i: Int, reversal: Boolean) { + if (pointList == null || pointList!!.size < 2) { + return + } + val ratio = SCALE * if (reversal) -1 else 1 + if (i < pointList!!.size - 2) { + val point = pointList!![i] + val nextPoint = pointList!![i + 1] + val midX = point.x + nextPoint.x shr 1 + if (i == 0) { + wavePath.moveTo(point.x.toFloat(), LUMP_MAX_HEIGHT - point.y * ratio) + } + wavePath.cubicTo(midX.toFloat(), LUMP_MAX_HEIGHT - point.y * ratio, + midX.toFloat(), LUMP_MAX_HEIGHT - nextPoint.y * ratio, + nextPoint.x.toFloat(), LUMP_MAX_HEIGHT - nextPoint.y * ratio) + + canvas.drawPath(wavePath, lumpPaint!!) + } + } + + private fun drawLump(canvas: Canvas, i: Int, reversal: Boolean) { + val minus = if (reversal) -1 else 1 + val top = LUMP_MAX_HEIGHT - (LUMP_MIN_HEIGHT + waveData!![i] * SCALE) * minus + + canvas.drawRect((LUMP_SIZE * i).toFloat(), + top, + (LUMP_SIZE * i + LUMP_WIDTH).toFloat(), + LUMP_MAX_HEIGHT.toFloat(), + lumpPaint!!) + } + + /** + * generate the waveform of sampling points + * + * @param data data + */ + private fun genSamplingPoint(data: ByteArray) { + if (upShowStyle != ShowStyle.STYLE_WAVE) { + return + } + if (pointList == null) { + pointList = ArrayList() + } else { + pointList!!.clear() + } + pointList!!.add(Point(0, 0)) + var i = WAVE_SAMPLING_INTERVAL + while (i < LUMP_COUNT) { + pointList!!.add(Point(LUMP_SIZE * i, waveData!![i].toInt())) + i += WAVE_SAMPLING_INTERVAL + } + pointList!!.add(Point(LUMP_SIZE * LUMP_COUNT, 0)) + } + + enum class ShowStyle { + STYLE_HOLLOW_LUMP, + STYLE_WAVE, + STYLE_NOTHING + } + + companion object { + + private const val LUMP_COUNT = 128 + private const val LUMP_WIDTH = 6 + private const val LUMP_SPACE = 2 + private const val LUMP_MIN_HEIGHT = LUMP_WIDTH + private const val LUMP_MAX_HEIGHT = 200 + private const val LUMP_SIZE = LUMP_WIDTH + LUMP_SPACE + private val LUMP_COLOR = Color.parseColor("#6de6f6") + + private const val WAVE_SAMPLING_INTERVAL = 3 + + private const val SCALE = (LUMP_MAX_HEIGHT / LUMP_COUNT).toFloat() + + private fun readyData(fft: ByteArray): ByteArray { + val newData = ByteArray(LUMP_COUNT) + var abs: Byte + for (i in 0 until LUMP_COUNT) { + abs = kotlin.math.abs(fft[i].toInt()).toByte() + newData[i] = if (abs < 0) 127 else abs + } + return newData + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_audio_effect.xml b/app/src/main/res/layout/activity_audio_effect.xml new file mode 100644 index 0000000..9bff88a --- /dev/null +++ b/app/src/main/res/layout/activity_audio_effect.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index bf0852d..62d0698 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -76,6 +76,13 @@ android:text="@string/media_probe" android:layout_marginTop="10dp" /> +