Android開發筆記(一百二十六)自定義音樂播放器
MediaRecorder/MediaPlayer
在Android手機上面,音訊的處理比視訊還要複雜,這真是出人意料。在前面的博文《Android開發筆記(五十七)錄影錄音與播放》中,介紹了視訊/音訊的錄製與播放,其中錄影用的是MediaRecorder類,播放用的是MediaPlayer類。雖然Android還提供了專門的視訊檢視VideoView,但是該控制元件並非新的東西,而是繼承了MediaRecorder和MediaPlayer,所以嚴格來說,Android上面只有一種視訊的錄製和播放方式。可是音訊就大不一樣了,Android提供了兩種錄音方式,以及至少三種常用的播音方式。兩種錄音方式分別是MediaRecorder類和AudioRecord類,而播音方式包括MediaPlayer類、AudioTrack類和SoundPool類,它們的使用場合各有千秋,且待筆者下面細細道來。首先是MediaRecorder與MediaPlayer,這對組合即可用於錄影,也可單獨錄製音訊。它們處理的音訊檔案是壓縮過的編碼檔案,通常用於錄製和播放音樂,是最經常用到的。MediaRecorder與MediaPlayer在處理音訊和視訊時,整體流程是一樣的,只有在部分方法的呼叫上有所差異,下面分別把錄音/播音有關的方法列出來。
MediaRecorder的錄音相關方法:
reset : 重置錄製資源
prepare : 準備錄製
start : 開始錄製
stop : 結束錄製
release : 釋放錄製資源
setOnErrorListener : 設定錯誤監聽器。可監聽伺服器異常以及未知錯誤的事件。
setOnInfoListener : 設定資訊監聽器。可監聽錄製結束事件,包括達到錄製時長或者達到錄製大小。
setAudioSource : 設定音訊來源。一般使用麥克風AudioSource.MIC。
setOutputFormat : 設定媒體輸出格式。OutputFormat.AMR_NB表示窄帶格式,OutputFormat.AMR_WB表示寬頻格式,AAC_ADTS表示高階的音訊傳輸流格式。該方法要在setVideoEncoder之前呼叫,不然呼叫setAudioEncoder時會報錯“java.lang.IllegalStateException”。
setAudioEncoder : 設定音訊編碼器。AudioEncoder.AMR_NB表示窄帶編碼,AudioEncoder.AMR_WB表示寬頻編碼,AudioEncoder.AAC表示低複雜度的高階編碼,AudioEncoder.HE_AAC表示高效率的高階編碼,AudioEncoder.AAC_ELD表示增強型低延遲的高階編碼。
注意:setAudioEncoder應在setOutputFormat之後執行,否則會出現“setAudioEncoder called in an invalid state(2)”的異常。
setAudioSamplingRate : 設定音訊的取樣率,單位赫茲(Hz)。該方法為可選,AMRNB預設8khz,AMRWB預設16khz。
setAudioChannels : 設定音訊的聲道數。1表示單聲道,2表示雙聲道。該方法為可選
setAudioEncodingBitRate : 設定音訊每秒錄製的位元組數。越大則音訊越清晰。該方法為可選
setMaxDuration : 設定錄製時長。單位毫秒。
setMaxFileSize : 設定錄製的媒體大小。單位位元組。
setOutputFile : 設定輸出檔案的路徑。
MediaPlayer的播音相關方法:
reset : 重置播放器
prepare : 準備播放
start : 開始播放
pause : 暫停播放
stop : 停止播放
setOnPreparedListener : 設定準備播放監聽器。
setOnCompletionListener : 設定結束播放監聽器。
setOnSeekCompleteListener : 設定播放拖動監聽器。
create : 建立指定Uri的播放器。
setDataSource : 設定播放資料來源。create與setDataSource只需設定其一。
setVolume : 設定音量。第一個引數是左聲道,第二個引數是右聲道,取值在0-1之間。
setAudioStreamType : 設定音訊流的型別。AudioManager.STREAM_MUSIC表示音樂,AudioManager.STREAM_RING表示鈴聲,AudioManager.STREAM_ALARM表示鬧鐘,AudioManager.STREAM_NOTIFICATION表示通知。
setLooping : 設定是否迴圈播放。
isPlaying : 判斷是否正在播放。
seekTo : 拖動播放進度到指定位置。
getCurrentPosition : 獲取當前播放進度所在的位置。
getDuration : 獲取播放時長。
下面是MediaRecorder與MediaPlayer組合處理音訊的示例程式碼:
import java.io.File;
import com.example.exmaudio.util.Utils;
import android.app.Activity;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.media.MediaRecorder.AudioEncoder;
import android.media.MediaRecorder.AudioSource;
import android.media.MediaRecorder.OnErrorListener;
import android.media.MediaRecorder.OnInfoListener;
import android.media.MediaRecorder;
import android.media.MediaRecorder.OutputFormat;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.util.Log;
import android.view.View.OnClickListener;
import android.view.View;
import android.view.Window;
import android.widget.Button;
import android.widget.TextView;
public class MediaRecordActivity extends Activity
implements OnClickListener, OnErrorListener, OnInfoListener {
private static final String TAG = "MediaRecordActivity";
private TextView tv_record;
private Button btn_start;
private Button btn_stop;
private MediaRecorder mMediaRecorder;
private TextView tv_play;
private Button btn_play;
private Button btn_pause;
private MediaPlayer mMediaPlayer;
private int mPosition;
private boolean bFirstPlay = true;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.activity_media_record);
tv_record = (TextView) findViewById(R.id.tv_record);
btn_start = (Button) findViewById(R.id.btn_start);
btn_stop = (Button) findViewById(R.id.btn_stop);
tv_play = (TextView) this.findViewById(R.id.tv_play);
btn_play = (Button) findViewById(R.id.btn_play);
btn_pause = (Button) findViewById(R.id.btn_pause);
btn_start.setOnClickListener(this);
btn_stop.setOnClickListener(this);
btn_play.setOnClickListener(this);
btn_pause.setOnClickListener(this);
btn_start.setEnabled(true);
btn_stop.setEnabled(false);
btn_play.setEnabled(false);
btn_pause.setEnabled(false);
initPlay();
}
private void initPlay() {
mMediaPlayer = new MediaPlayer();
mMediaPlayer.setOnCompletionListener(new OnCompletionListener() {
@Override
public void onCompletion(MediaPlayer mp) {
btn_play.setEnabled(true);
btn_pause.setEnabled(false);
bFirstPlay = true;
mHandler.removeCallbacks(mPlayRun);
mPlayTime = 0;
}
});
}
private void preplay() {
try {
mMediaPlayer.reset();
//mMediaPlayer.setVolume(0.5f, 0.5f); //設定音量,可選
mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
String path = mRecordFile.getAbsolutePath();
mMediaPlayer.setDataSource(path);
Log.d(TAG, "audio path = "+path);
mMediaPlayer.prepare();
} catch (Exception e) {
Log.d(TAG, "mMediaPlayer.prepare error: "+e.getMessage());
}
mPlayTime = 0;
}
private void startPlay() {
try {
if (bFirstPlay == true) {
preplay();
bFirstPlay = false;
}
mMediaPlayer.start();
} catch (Exception e) {
Log.d(TAG, "mMediaPlayer.start error: " + e.getMessage());
}
btn_play.setEnabled(false);
btn_pause.setEnabled(true);
mHandler.post(mPlayRun);
}
@Override
protected void onPause() {
// 先判斷是否正在播放
if (mMediaPlayer.isPlaying()) {
// 如果正在播放我們就先儲存這個播放位置
mPosition = mMediaPlayer.getCurrentPosition();
mMediaPlayer.stop();
mHandler.removeCallbacks(mPlayRun);
}
super.onPause();
}
@Override
protected void onResume() {
if (mMediaPlayer!=null && mPosition>0) {
mMediaPlayer.seekTo(mPosition);
mMediaPlayer.start();
mHandler.post(mPlayRun);
}
super.onResume();
}
private void startRecord() {
createRecordDir();
mMediaRecorder = new MediaRecorder();
mMediaRecorder.reset();
mMediaRecorder.setOnErrorListener(this);
mMediaRecorder.setOnInfoListener(this);
mMediaRecorder.setAudioSource(AudioSource.MIC); //音訊源
mMediaRecorder.setOutputFormat(OutputFormat.AMR_NB);
mMediaRecorder.setAudioEncoder(AudioEncoder.AMR_NB); //音訊格式
//mMediaRecorder.setAudioSamplingRate(8); //音訊的取樣率。可選
//mMediaRecorder.setAudioChannels(2); //音訊的聲道數。可選
//mMediaRecorder.setAudioEncodingBitRate(1024); //音訊每秒錄製的位元組數。可選
mMediaRecorder.setMaxDuration(10 * 1000); //設定錄製時長
//mMediaRecorder.setMaxFileSize(1024*1024*10); //setMaxFileSize與setMaxDuration設定其一即可
mMediaRecorder.setOutputFile(mRecordFile.getAbsolutePath());
try {
mMediaRecorder.prepare();
mMediaRecorder.start();
} catch (Exception e) {
Log.d(TAG, "mMediaRecorder.start error: " + e.getMessage());
}
btn_start.setEnabled(false);
btn_stop.setEnabled(true);
mRecordTime = 0;
mHandler.post(mRecordRun);
}
private File mRecordFile = null;
private void createRecordDir() {
File sampleDir = new File(Environment.getExternalStorageDirectory()
+ File.separator + "Download" + File.separator);
if (!sampleDir.exists()) {
sampleDir.mkdirs();
}
File recordDir = sampleDir;
try {
mRecordFile = File.createTempFile(Utils.getNowDateTime(), ".amr", recordDir);
Log.d(TAG, mRecordFile.getAbsolutePath());
} catch (Exception e) {
Log.d(TAG, "createTempFile error: " + e.getMessage());
}
}
private void stopRecord() {
if (mMediaRecorder != null) {
mMediaRecorder.setOnErrorListener(null);
mMediaRecorder.setPreviewDisplay(null);
try {
mMediaRecorder.stop();
} catch (Exception e) {
Log.d(TAG, "mMediaRecorder.stop error: " + e.getMessage());
}
mMediaRecorder.release();
mMediaRecorder = null;
}
btn_start.setEnabled(true);
btn_stop.setEnabled(false);
btn_play.setEnabled(true);
mHandler.removeCallbacks(mRecordRun);
}
@Override
public void onClick(View v) {
int resid = v.getId();
if (resid == R.id.btn_start) {
startRecord();
} else if (resid == R.id.btn_stop) {
stopRecord();
} else if (resid == R.id.btn_play) {
startPlay();
} else if (resid == R.id.btn_pause) {
mMediaPlayer.pause();
btn_play.setEnabled(true);
btn_pause.setEnabled(false);
mHandler.removeCallbacks(mPlayRun);
}
}
private Handler mHandler = new Handler();
private int mRecordTime = 0;
private Runnable mRecordRun = new Runnable() {
@Override
public void run() {
tv_record.setText(mRecordTime+"s");
mRecordTime++;
mHandler.postDelayed(this, 1000);
}
};
private int mPlayTime = 0;
private Runnable mPlayRun = new Runnable() {
@Override
public void run() {
tv_play.setText(mPlayTime+"s");
mPlayTime++;
mHandler.postDelayed(this, 1000);
}
};
@Override
public void onError(MediaRecorder mr, int what, int extra) {
if (mr != null) {
mr.reset();
}
}
@Override
public void onInfo(MediaRecorder mr, int what, int extra) {
if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED
|| what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
stopRecord();
}
}
}
AudioRecord/AudioTrack
話說Android搞出這麼多種錄音/播音方式,到底有什麼用處呢?其實這還是跟不同的需求和用途有關,譬如說語音通話,要求實時傳輸,手機這邊說一句話,那邊廂就同步聽到一句話。如果是MediaRecorder與MediaPlayer組合,只能整句話都錄完編碼好了,才能傳給對方去播放,這個實效性就太差了。於是適用於音訊實時處理的AudioRecord與AudioTrack組合就應運而生,該組合的音訊為原始的二進位制音訊資料,沒有檔案頭和檔案尾,故而可以實現邊錄邊播的實時語音。MediaRecorder錄製的音訊格式有amr、aac等,MediaPlayer支援播放的音訊格式除了amr、aac之外,還支援常見的mp3、wav、mid、ogg等經過壓縮編碼的音訊。AudioRecord錄製的音訊格式只有pcm,AudioTrack可直接播放的也只有pcm。pcm格式有個缺點,在播放過程中不能直接暫停,因為二進位制流;但pcm格式有個好處,就是iOS不能播放amr音訊,但能播放pcm音訊;所以如果Android手機錄製的音樂需要傳給iOS手機播放,還是得采用pcm格式。
下面是AudioRecord與AudioTrack組合的錄音/播音相關說明。
AudioRecord的錄音相關方法:
getMinBufferSize : 根據取樣頻率、聲道配置、音訊格式獲得合適的緩衝區大小。該函式為靜態方法。
建構函式 : 可設定錄音來源、取樣頻率、聲道配置、音訊格式與緩衝區大小。其中錄音來源一般是AudioSource.MIC,取樣頻率可取值8000或者16000,聲道配置可取值AudioFormat.CHANNEL_IN_STEREO或者AudioFormat.CHANNEL_OUT_STEREO,音訊格式可取值AudioFormat.ENCODING_PCM_16BIT或者AudioFormat.ENCODING_PCM_8BIT。
startRecording : 開始錄音。
read : 從緩衝區中讀取音訊資料,此資料用於儲存到音訊檔案中。
stop : 停止錄音。
release : 停止錄音並釋放資源。
setNotificationMarkerPosition : 設定需要通知的標記位置。
setPositionNotificationPeriod : 設定需要通知的時間週期。
setRecordPositionUpdateListener : 設定錄製位置變化的監聽器物件。該監聽器從OnRecordPositionUpdateListener擴充套件而來,需要實現onMarkerReached和onPeriodicNotification兩個方法;其中onMarkerReached事件的觸發對應於setNotificationMarkerPosition方法,onPeriodicNotification事件的觸發對應於setPositionNotificationPeriod方法。
AudioTrack的播音相關方法:
getMinBufferSize : 根據取樣頻率、聲道配置、音訊格式獲得合適的緩衝區大小。該函式為靜態方法。
建構函式 : 可設定音訊型別、取樣頻率、聲道配置、音訊格式、播放模式與緩衝區大小。其中音訊型別一般是AudioManager.STREAM_MUSIC,取樣頻率、聲道配置、音訊格式與錄音時保持一致,播放模式一般是AudioTrack.MODE_STREAM。
setStereoVolume : 設定立體聲的音量。第一個引數是左聲道音量,第二個引數是右聲道音量。
play : 開始播放。
write : 把緩衝區的音訊資料寫入音軌中。呼叫該函式前要先從音訊檔案中讀取資料寫入緩衝區。
stop : 停止播放。
release : 停止播放並釋放資源。
setNotificationMarkerPosition : 設定需要通知的標記位置。
setPositionNotificationPeriod : 設定需要通知的時間週期。
setPlaybackPositionUpdateListener : 設定播放位置變化的監聽器物件。該監聽器從OnPlaybackPositionUpdateListener擴充套件而來,需要實現onMarkerReached和onPeriodicNotification兩個方法;其中onMarkerReached事件的觸發對應於setNotificationMarkerPosition方法,onPeriodicNotification事件的觸發對應於setPositionNotificationPeriod方法。
下面是AudioRecord與AudioTrack組合處理音訊的示例程式碼:
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import com.example.exmaudio.util.Utils;
import android.app.Activity;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioRecord.OnRecordPositionUpdateListener;
import android.media.AudioTrack.OnPlaybackPositionUpdateListener;
import android.media.AudioTrack;
import android.media.MediaRecorder.AudioSource;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.TextView;
public class AudioRecordActivity extends Activity implements OnClickListener {
private static final String TAG = "AudioRecordActivity";
private TextView tv_record, tv_play;
private Button btn_start, btn_stop, btn_play, btn_finish;
private boolean isRecording, isPlaying;
private Handler mHandler = new Handler();
private int mRecordTime, mPlayTime;
private int frequence = 8000;
private int channelConfig = AudioFormat.CHANNEL_IN_STEREO; //只能取值CHANNEL_OUT_STEREO
//如果取值CHANNEL_OUT_DEFAULT,會報錯“getMinBufferSize(): Invalid channel configuration.”
//如果取值CHANNEL_OUT_MONO,會報錯“java.lang.IllegalArgumentException: Unsupported channel configuration.”
private int audioFormat = AudioFormat.ENCODING_PCM_16BIT; //AudioRecord只能錄製PCM格式
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_audio_record);
tv_record = (TextView) findViewById(R.id.tv_record);
btn_start = (Button) findViewById(R.id.btn_start);
btn_stop = (Button) findViewById(R.id.btn_stop);
tv_play = (TextView) findViewById(R.id.tv_play);
btn_play = (Button) findViewById(R.id.btn_play);
btn_finish = (Button) findViewById(R.id.btn_finish);
btn_start.setEnabled(true);
btn_stop.setEnabled(false);
btn_play.setEnabled(false);
btn_finish.setEnabled(false);
btn_start.setOnClickListener(this);
btn_stop.setOnClickListener(this);
btn_play.setOnClickListener(this);
btn_finish.setOnClickListener(this);
createRecordDir();
}
private File mRecordFile = null;
private void createRecordDir() {
File sampleDir = new File(Environment.getExternalStorageDirectory()
+ File.separator + "Download" + File.separator);
if (!sampleDir.exists()) {
sampleDir.mkdirs();
}
File recordDir = sampleDir;
try {
mRecordFile = File.createTempFile(Utils.getNowDateTime(), ".pcm", recordDir);
Log.d(TAG, mRecordFile.getAbsolutePath());
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onClick(View v) {
int resid = v.getId();
if (resid == R.id.btn_start) {
isRecording = true;
new RecordTask().execute();
} else if (resid == R.id.btn_stop) {
isRecording = false;
} else if (resid == R.id.btn_play) {
isPlaying = true;
new PlayTask().execute();
} else if (resid == R.id.btn_finish) {
isPlaying = false;
}
}
private void refreshStatus(boolean isRecord, boolean isPlay) {
if (isRecord || isPlay) {
btn_start.setEnabled(false);
btn_stop.setEnabled(isRecord?true:false);
btn_play.setEnabled(false);
btn_finish.setEnabled(isPlay?true:false);
} else {
btn_start.setEnabled(true);
btn_stop.setEnabled(false);
btn_play.setEnabled(true);
btn_finish.setEnabled(false);
}
}
private class RecordTask extends AsyncTask<Void, Integer, Void> {
@Override
protected Void doInBackground(Void... arg0) {
try {
// 開通輸出流到指定的檔案
DataOutputStream dos = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(mRecordFile)));
// 根據定義好的幾個配置,來獲取合適的緩衝大小
int bsize = AudioRecord.getMinBufferSize(frequence, channelConfig, audioFormat);
AudioRecord record = new AudioRecord(AudioSource.MIC,
frequence, channelConfig, audioFormat, bsize);
// 定義緩衝區
short[] buffer = new short[bsize];
//record.setNotificationMarkerPosition(1000);
record.setPositionNotificationPeriod(1000);
record.setRecordPositionUpdateListener(new RecordUpdateListener());
record.startRecording();
while (isRecording) {
int bufferReadResult = record.read(buffer, 0, buffer.length);
// 迴圈將buffer中的音訊資料寫入到OutputStream中
for (int i = 0; i < bufferReadResult; i++) {
dos.writeShort(buffer[i]);
}
}
record.stop();
dos.close();
Log.d(TAG, "mRecordFile.length()=" + mRecordFile.length());
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPreExecute() {
refreshStatus(true, false);
mRecordTime = 0;
mHandler.postDelayed(mRecordRun, 1000);
}
@Override
protected void onPostExecute(Void result) {
refreshStatus(false, false);
mHandler.removeCallbacks(mRecordRun);
}
}
private Runnable mRecordRun = new Runnable() {
@Override
public void run() {
mRecordTime++;
mHandler.postDelayed(this, 1000);
}
};
private class RecordUpdateListener implements OnRecordPositionUpdateListener {
@Override
public void onMarkerReached(AudioRecord recorder) {
}
@Override
public void onPeriodicNotification(AudioRecord recorder) {
tv_record.setText(mRecordTime+"s");
}
}
private class PlayTask extends AsyncTask<Void, Integer, Void> {
@Override
protected Void doInBackground(Void... arg0) {
try {
// 定義輸入流,將音訊寫入到AudioTrack類中,實現播放
DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(mRecordFile)));
int bsize = AudioTrack.getMinBufferSize(frequence, channelConfig, audioFormat);
short[] buffer = new short[bsize / 4];
AudioTrack track = new AudioTrack(AudioManager.STREAM_MUSIC,
frequence, channelConfig, audioFormat, bsize, AudioTrack.MODE_STREAM);
//track.setNotificationMarkerPosition(1000);
track.setPositionNotificationPeriod(1000);
track.setPlaybackPositionUpdateListener(new PlaybackUpdateListener());
track.play();
// 由於AudioTrack播放的是流,所以,我們需要一邊播放一邊讀取
while (isPlaying && dis.available() > 0) {
int i = 0;
while (dis.available() > 0 && i < buffer.length) {
buffer[i] = dis.readShort();
i++;
}
// 然後將資料寫入到AudioTrack中
track.write(buffer, 0, buffer.length);
}
track.stop();
dis.close();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
@Override
protected void onPreExecute() {
refreshStatus(false, true);
mPlayTime = 0;
mHandler.postDelayed(mPlayRun, 1000);
}
@Override
protected void onPostExecute(Void result) {
refreshStatus(false, false);
mHandler.removeCallbacks(mPlayRun);
}
}
private Runnable mPlayRun = new Runnable() {
@Override
public void run() {
mPlayTime++;
mHandler.postDelayed(this, 1000);
}
};
private class PlaybackUpdateListener implements OnPlaybackPositionUpdateListener {
@Override
public void onMarkerReached(AudioTrack track) {
}
@Override
public void onPeriodicNotification(AudioTrack track) {
tv_play.setText(mPlayTime+"s");
}
}
}
SoundPool
App使用過程中經常有些短小的提示聲音,比如拍照的咔嚓聲、掃一掃的吡一聲,還有玩遊戲擊中目標的嗒嗒聲,這些片段聲音基本是系統自帶的。如果使用MediaPlayer來播放,便存在諸如下面的不足之處:資源佔用量較高、延遲時間較長、不支援多個音訊同時播放等等。因此,我們需要一個短聲音專用的播放器,這個播放器在Android中就是SoundPool。SoundPool在使用時可以事先載入多個音訊,然後在需要的時候播放指定編號的音訊,這樣處理有幾個好處:
1、資源佔用量小,不像MediaPlayer那麼重;
2、延遲時間相對MediaPlayer延遲非常小;
3、可以同時播放多個音訊,從而實現遊戲過程中多個有效疊加的情景;
當然,SoundPool帶來方便的同時也做了一部分犧牲,下面是使用它的一些限制:
1、SoundPool最大隻能申請1M的記憶體,這意味著它只能播放一些很短的聲音片段,不能用於播放歌曲或者遊戲背景音樂;
2、雖然SoundPool提供了pause和stop方法,但是輕易不要使用這兩個方法,因為它們可能會讓你的App異常或崩潰;
3、SoundPool播放的音訊格式建議使用ogg格式,據說它對wav格式的支援不太好;
4、待播放的音訊要提前載入進SoundPool,不要等到要播放的時候才載入。因為SoundPool不會等音訊載入完了才播放,所以它的延遲才比較小;而MediaPlayer會等待載入完畢才播放,所以延遲會比較大。
下面是SoundPool的常用方法說明:
建構函式 : 可設定最大個數、音訊型別、音訊質量。其中音訊型別一般是AudioManager.STREAM_MUSIC,質量取值為0到100。
load : 載入指定的音訊,該音訊可以是個磁碟檔案,也可以是資原始檔。返回值為該音訊的編號。
unload : 解除安裝指定編號的音訊。
play : 播放指定編號的音訊。可同時設定左右聲道的音量(取值為0.0到1.0)、優先順序(0為最低)、是否迴圈播放(0為只播放一次,-1為無限迴圈)、播放速率(取值為0.5-2.0,其中1.0為正常速率)。
setVolume : 設定指定編號音訊的音量大小。
setPriority : 設定指定編號音訊的優先順序。
setLoop : 設定指定編號的音訊是否迴圈播放。
setRate : 設定指定編號音訊的播放速率。
pause : 暫停播放指定編號的音訊。
resume : 恢復播放指定編號的音訊。
autoPause : 暫停所有正在播放的音訊。
autoResume : 恢復播放所有被暫停的音訊。
stop : 停止播放指定編號的音訊。
release : 釋放所有音訊資源。
setOnLoadCompleteListener : 設定音訊載入完畢的監聽器。該監聽器擴充套件自OnLoadCompleteListener,需要重寫onLoadComplete方法。
下面是SoundPool播放音訊的示例程式碼:
import java.util.HashMap;
import android.app.Activity;
import android.media.AudioManager;
import android.media.SoundPool;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
public class SoundPlayActivity extends Activity implements OnClickListener {
private SoundPool mSoundPool;
private HashMap<Integer, Integer> mSoundMap;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_sound_play);
Button btn_play_all = (Button) findViewById(R.id.btn_play_all);
Button btn_play_first = (Button) findViewById(R.id.btn_play_first);
Button btn_play_second = (Button) findViewById(R.id.btn_play_second);
Button btn_play_third = (Button) findViewById(R.id.btn_play_third);
btn_play_all.setOnClickListener(this);
btn_play_first.setOnClickListener(this);
btn_play_second.setOnClickListener(this);
btn_play_third.setOnClickListener(this);
mSoundMap = new HashMap<Integer, Integer>();
mSoundPool = new SoundPool(3, AudioManager.STREAM_MUSIC, 100);
loadSound(1, R.raw.beep1);
loadSound(2, R.raw.beep2);
loadSound(3, R.raw.ring);
}
private void loadSound(int seq, int resid) {
int soundID = mSoundPool.load(this, resid, 1);
mSoundMap.put(seq, soundID);
}
private void playSound(int seq) {
int soundID = mSoundMap.get(seq);
mSoundPool.play(soundID, 1.0f, 1.0f, 1, 0, 1.0f);
}
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_play_all) {
playSound(1);
playSound(2);
playSound(3);
} else if (v.getId() == R.id.btn_play_first) {
playSound(1);
} else if (v.getId() == R.id.btn_play_second) {
playSound(2);
} else if (v.getId() == R.id.btn_play_third) {
playSound(3);
}
}
@Override
protected void onDestroy() {
if (mSoundPool != null) {
mSoundPool.release();
}
super.onDestroy();
}
}
自定義音樂播放器
大家常見的音樂播放器,不外乎主要有三項功能:1、展示音樂/歌曲列表;
2、滾動展示歌詞,並高亮顯示當前正在播放的詞句;
3、展示控制欄顯示播放進度,並提供開始/暫停、拖動播放的功能,以及同時控制歌詞的滾動情況;
對於第一點的展示歌曲列表,通過手工新增很費時費力,而且使用者往往搞不清楚手機上的歌曲都放在哪個目錄。我們假設使用者是傻白甜,那自己開發的App就得智慧貼心,主動幫使用者把手機上的歌曲找出來。要實現這個功能,就到系統自帶的媒體庫中去查詢,媒體庫裡音訊資源的詳細路徑是MediaStore.Audio.Media.EXTERNAL_CONTENT_URI這個Uri,訪問裡面的音訊記錄,可以通過ContentResolver來完成。有關ContentResolver的具體用法參見《Android開發筆記(五十四)資料共享介面ContentProvider》。下面是MediaStore.Audio.Media.EXTERNAL_CONTENT_URI裡的主要欄位資訊說明:
Audio.Media._ID : 歌曲編號。
Audio.Media.TITLE : 歌曲的標題名稱。
Audio.Media.ALBUM : 歌曲的專輯名稱。
Audio.Media.DURATION : 歌曲的播放時間。
Audio.Media.SIZE : 歌曲檔案的賭大小。
Audio.Media.ARTIST : 歌曲的演唱者。
Audio.Media.DATA : 歌曲檔案的完整路徑。
對於第二點的滾動歌詞顯示,通用的歌詞檔案是lrc格式的文字檔案,內容主要是每句歌詞的文字與開始時間。文字檔案的解析並不複雜,難點主要在滾動顯示上面。乍看起來歌詞從下往上滾動,採用平移動畫TranslateAnimation正合適;可是歌詞滾動可不是勻速的,因為每句歌詞的間隔時間並不固定,只能把整個歌詞滾動分解為若干個動畫,每個平移動畫只負責前後兩行歌詞之間的滾動效果,前一行歌詞的平移動畫滾動完畢,馬上開始下一行歌詞的平移動畫。另外,高亮顯示當前演奏的歌詞,這等於一段文字內的部分文字風格改變,雖然可以讓每行文字都用單獨的TextView來展示,但是一堆的TextView控制元件同時滾動很影響UI效能,所以建議採用可變字串SpannableString直接處理段內文字,它的具體說明參見《Android開發筆記(六)可變字串》。
對於第三點的歌曲控制欄,總體上覆用前一篇博文提到的視訊控制欄VideoController,博文名稱是《Android開發筆記(一百二十五)自定義視訊播放器》。不過歌曲控制欄還要更復雜,因為除了控制音訊的播放,還要控制歌詞動畫的播放。更要命的是,平移動畫TranslateAnimation居然不支援暫停和恢復操作,而且不只是平移動畫,所有補間動畫都不支援暫停和恢復。難道又要自己重定義動畫了嗎?剛想到這個的時候,不要說讀者,就連筆者自己都想撞牆了。山窮水盡疑無路,柳暗花明又一村,幸好Android還給我們提供了屬性動畫這麼一個好東東,屬性動畫不但支援所有的補間動畫效果,而且也支援暫停和恢復操作,所以還等什麼,趕緊把TranslateAnimation換成了ObjectAnimator。有關屬性動畫的詳細介紹參見《Android開發筆記(九十六)集合動畫與屬性動畫》。
弄完以上三點功能,一個主流音樂播放器的雛形便出來了,下面是音樂播放器的歌曲列表截圖:
下面是音樂播放器的歌曲詳情頁的效果截圖:
下面是音樂播放器的歌曲詳情頁面的程式碼例子:
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.FileInputStream;
import java.util.ArrayList;
import com.example.exmaudio.bean.LrcContent;
import com.example.exmaudio.bean.MusicInfo;
import com.example.exmaudio.util.LyricsLoader;
import com.example.exmaudio.util.Utils;
import com.example.exmaudio.widget.AudioController;
import com.example.exmaudio.widget.AudioController.onSeekChangeListener;
import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.ObjectAnimator;
import android.annotation.TargetApi;
import android.app.Activity;
import android.graphics.Color;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioTrack;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import android.view.View;
import android.view.animation.AnimationUtils;
import android.widget.TextView;
@TargetApi(Build.VERSION_CODES.KITKAT)
public class MusicDetailActivity extends Activity
implements AnimatorListener, onSeekChangeListener {
private static final String TAG = "MusicDetailActivity";
private TextView tv_title;
private TextView tv_artist;
private TextView tv_music;
private MusicInfo mMusic;
private MediaPlayer mMediaPlayer;
private AudioController ac_play;
private LyricsLoader mLoader;
private ArrayList<LrcContent> mLrcList;
private Handler mHandler = new Handler();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_music_detail);
tv_title = (TextView) findViewById(R.id.tv_title);
tv_artist = (TextView) findViewById(R.id.tv_artist);
tv_music = (TextView) findViewById(R.id.tv_music);
ac_play = (AudioController) findViewById(R.id.ac_play);
ac_play.setonSeekChangeListener(this);
mMusic = getIntent().getParcelableExtra("music");
tv_title.setText(mMusic.getTitle());
tv_artist.setText(mMusic.getArtist());
mLoader = LyricsLoader.getInstance(mMusic.getUrl());
mLrcList = mLoader.getLrcList();
mMediaPlayer = new MediaPlayer();
playMusic(mMusic.getUrl());
}
private void playMusic(String file_path) {
if (mMediaPlayer.isPlaying()) {
mMediaPlayer.stop();
}
if (Utils.getExtendName(file_path).equals("pcm")) {
ac_play.setVisibility(View.GONE);
PlayTask playTask = new PlayTask();
playTask.execute(file_path);
} else {
playMedia(file_path);
}
}
private void playMedia(String filePath) {
try {
mMediaPlayer.reset();
//mMediaPlayer.setVolume(0.5f, 0.5f); //設定音量,可選
mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
mMediaPlayer.setDataSource(filePath);
mMediaPlayer.prepare();
mMediaPlayer.start();
mHandler.post(mRefreshCtrl);
mMediaPlayer.setOnCompletionListener(new OnCompletionListener() {
@Override
public void onCompletion(MediaPlayer mp) {
ac_play.setCurrentTime(0, 0);
}
});
ac_play.setMediaPlayer(mMediaPlayer);
//以下處理歌詞
if (mLoader.getLrcList()!=null && mLrcList.size()>0) {
mLrcStr = "";
for (int i=0; i<mLrcList.size(); i++) {
LrcContent item = mLrcList.get(i);
mLrcStr = mLrcStr + item.getLrcStr() + "\n";
}
tv_music.setText(mLrcStr);
tv_music.setAnimation(AnimationUtils.loadAnimation(this,R.anim.alpha));
mHandler.postDelayed(mRefreshLrc, 100);
}
} catch (Exception e) {
Log.d(TAG, "mMediaPlayer.prepare error: "+e.getMessage());
}
}
@Override
protected void onDestroy() {
super.onDestroy();
mHandler.removeCallbacksAndMessages(null);
}
private Runnable mRefreshCtrl = new Runnable() {
@Override
public void run() {
if (mMediaPlayer.isPlaying()) {
ac_play.setCurrentTime(mMediaPlayer.getCurrentPosition(), 0);
}
mHandler.postDelayed(this, 500);
}
};
@Override
public void onMusicSeek(int current, int seekto) {
Log.d(TAG, "current="+current+", seekto="+seekto);
animTranY.cancel();
mHandler.removeCallbacks(mRefreshLrc);
int i;
for (i=0; i<mLrcList.size(); i++) {
LrcContent item = mLrcList.get(i);
if (item.getLrcTime() > seekto) {
break;
}
}
mCount = i;
mPrePos = -1;
mNextPos = 0;
if (mCount > 0) {
for (int j = 0; j < mCount; j++) {
mNextPos = mLrcStr.indexOf("\n", mPrePos + 1);
mPrePos = mLrcStr.indexOf("\n", mNextPos);
}
}
startAnimation(-mLineHeight*i, 100);
}
@Override
public void onMusicPause() {
animTranY.pause();
}
@Override
public void onMusicResume() {
animTranY.resume();
}
private int mCount = 0;
private float mCurrentHeight = 0;
private float mLineHeight = 0;
private Runnable mRefreshLrc = new Runnable() {
@Override
public void run() {
if (mLineHeight == 0) {
mLineHeight = (float) (tv_music.getHeight()-tv_music.getPaddingTop())
/mLrcList.size()/2;
Log.d(TAG, "tv_music.getHeight()="+tv_music.getHeight());
Log.d(TAG, "tv_music.getPaddingTop()="+tv_music.getPaddingTop());
Log.d(TAG, "mLineHeight="+mLineHeight);
}
int offset = mLrcList.get(mCount).getLrcTime()
- ((mCount==0)?0:mLrcList.get(mCount-1).getLrcTime()) - 50;
if (offset <= 0) {
return;
}
startAnimation(mCurrentHeight - mLineHeight, offset);
Log.d(TAG, "mLineHeight="+mLineHeight+",mCurrentHeight="+mCurrentHeight+",getHeight="+tv_music.getHeight());
}
};
private int mPrePos = -1, mNextPos = 0;
private String mLrcStr;
private ObjectAnimator animTranY;
public void startAnimation(float aimHeight, int offset) {
Log.d(TAG, "mCurrentHeight="+mCurrentHeight+", aimHeight="+aimHeight);
animTranY = ObjectAnimator.ofFloat(tv_music, "translationY",
mCurrentHeight, aimHeight);
animTranY.setDuration(offset);
animTranY.setRepeatCount(0);
animTranY.addListener(this);
animTranY.start();
mCurrentHeight = aimHeight;
}
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
if (mCount < mLrcList.size()) {
mNextPos = mLrcStr.indexOf("\n", mPrePos+1);
SpannableString spanText = new SpannableString(mLrcStr);
spanText.setSpan(new ForegroundColorSpan(Color.RED), mPrePos+1,
mNextPos>0?mNextPos:mLrcStr.length()-1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
mCount++;
tv_music.setText(spanText);
if (mNextPos > 0 && mNextPos < mLrcStr.length()-1) {
mPrePos = mLrcStr.indexOf("\n", mNextPos);
mHandler.postDelayed(mRefreshLrc, 50);
}
}
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
private int frequence = 8000;
private int channelConfig = AudioFormat.CHANNEL_IN_STEREO; //只能取值CHANNEL_OUT_STEREO
private int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
private class PlayTask extends AsyncTask<String, Integer, Void> {
@Override
protected Void doInBackground(String... arg0) {
try {
// 定義輸入流,將音訊寫入到AudioTrack類中,實現播放
DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(arg0[0])));
int bsize = AudioTrack.getMinBufferSize(frequence, channelConfig, audioFormat);
short[] buffer = new short[bsize / 4];
AudioTrack track = new AudioTrack(AudioManager.STREAM_MUSIC,
frequence, channelConfig, audioFormat, bsize, AudioTrack.MODE_STREAM);
track.play();
// 由於AudioTrack播放的是流,所以,我們需要一邊播放一邊讀取
while (dis.available() > 0) {
int i = 0;
while (dis.available() > 0 && i < buffer.length) {
buffer[i] = dis.readShort();
i++;
}
// 然後將資料寫入到AudioTrack中
track.write(buffer, 0, buffer.length);
}
track.stop();
dis.close();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
}
下面是音樂播放器的歌曲控制欄的程式碼例子:
import com.example.exmaudio.R;
import com.example.exmaudio.util.Utils;
import android.content.Context;
import android.graphics.Color;
import android.media.MediaPlayer;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.SeekBar;
import android.widget.SeekBar.OnSeekBarChangeListener;
import android.widget.TextView;
public class AudioController extends RelativeLayout implements OnClickListener, OnSeekBarChangeListener {
private static final String TAG = "AudioController";
private Context mContext;
private ImageView mImagePlay;
private TextView mCurrentTime;
private TextView mTotalTime;
private SeekBar mSeekBar;
private int mBeginViewId = 0x7F24FFF0;
private int dip_10, dip_40;
private MediaPlayer mMediaPlayer;
private int mCurrent = 0;
private int mBuffer = 0;
private int mDuration = 0;
private boolean bPause = false;
public AudioController(Context context) {
this(context, null);
}
public AudioController(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
dip_10 = Utils.dip2px(mContext, 10);
dip_40 = Utils.dip2px(mContext, 40);
initView();
}
private TextView newTextView(Context context, int id) {
TextView tv = new TextView(context);
tv.setId(id);
tv.setGravity(Gravity.CENTER);
tv.setTextColor(Color.WHITE);
tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
params.addRule(RelativeLayout.CENTER_VERTICAL);
tv.setLayoutParams(params);
return tv;
}
private void initView() {
mImagePlay = new ImageView(mContext);
RelativeLayout.LayoutParams imageParams = new RelativeLayout.LayoutParams(dip_40, dip_40);
imageParams.addRule(RelativeLayout.CENTER_VERTICAL);
mImagePlay.setLayoutParams(imageParams);
mImagePlay.setId(mBeginViewId);
mImagePlay.setOnClickListener(this);
mCurrentTime = newTextView(mContext, mBeginViewId+1);
RelativeLayout.LayoutParams currentParams = (LayoutParams) mCurrentTime.getLayoutParams();
currentParams.setMargins(dip_10, 0, 0, 0);
currentParams.addRule(RelativeLayout.RIGHT_OF, mImagePlay.getId());
mCurrentTime.setLayoutParams(currentParams);
mTotalTime = newTextView(mContext, mBeginViewId+2);
RelativeLayout.LayoutParams totalParams = (LayoutParams) mTotalTime.getLayoutParams();
totalParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
mTotalTime.setLayoutParams(totalParams);
mSeekBar = new SeekBar(mContext);
RelativeLayout.LayoutParams seekParams = new RelativeLayout.LayoutParams(
LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
totalParams.setMargins(dip_10, 0, dip_10, 0);
seekParams.addRule(RelativeLayout.CENTER_IN_PARENT);
seekParams.addRule(RelativeLayout.RIGHT_OF, mCurrentTime.getId());
seekParams.addRule(RelativeLayout.LEFT_OF, mTotalTime.getId());
mSeekBar.setLayoutParams(seekParams);
mSeekBar.setMax(100);
mSeekBar.setMinimumHeight(100);
mSeekBar.setThumbOffset(0);
mSeekBar.setId(mBeginViewId+3);
mSeekBar.setOnSeekBarChangeListener(this);
}
private void reset() {
if (mCurrent == 0 || bPause) {
mImagePlay.setImageResource(R.drawable.audio_btn_down);
} else {
mImagePlay.setImageResource(R.drawable.audio_btn_on);
}
mCurrentTime.setText(Utils.formatTime(mCurrent));
mTotalTime.setText(Utils.formatTime(mDuration));
mSeekBar.setProgress((mCurrent==0)?0:(mCurrent*100/mDuration));
mSeekBar.setSecondaryProgress(mBuffer);
}
private void refresh() {
invalidate();
requestLayout();
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
removeAllViews();
reset();
addView(mImagePlay);
addView(mCurrentTime);
addView(mTotalTime);
addView(mSeekBar);
}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (fromUser) {
int time = progress * mDuration / 100;
mMediaPlayer.seekTo(time);
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
int time = seekBar.getProgress() * mDuration / 100;
mSeekListener.onMusicSeek(mMediaPlayer.getCurrentPosition(), time);
}
private onSeekChangeListener mSeekListener;
public static interface onSeekChangeListener {
public void onMusicSeek(int current, int seekto);
public void onMusicPause();
public void onMusicResume();
}
public void setonSeekChangeListener(onSeekChangeListener listener) {
mSeekListener = listener;
}
@Override
public void onClick(View v) {
if (v.getId() == mImagePlay.getId()) {
if (mMediaPlayer.isPlaying()) {
mMediaPlayer.pause();
bPause = true;
mSeekListener.onMusicPause();
} else {
if (mCurrent == 0) {
mSeekListener.onMusicSeek(0, 0);
}
mMediaPlayer.start();
bPause = false;
mSeekListener.onMusicResume();
}
}
refresh();
}
public void setMediaPlayer(MediaPlayer view) {
mMediaPlayer = view;
mDuration = mMediaPlayer.getDuration();
}
public void setCurrentTime(int current_time, int buffer_time) {
mCurrent = current_time;
mBuffer = buffer_time;
refresh();
}
}
點選下載本文用到的自定義音樂播放器的工程程式碼
點此檢視Android開發筆記的完整目錄
相關文章
- Android開發筆記(一百二十五)自定義視訊播放器Android筆記播放器
- Android 音樂播放器開發實錄(MediaSession)Android播放器Session
- Android開源線上音樂播放器——波尼音樂Android播放器
- 音樂播放器的開發播放器
- Android自定義View–仿QQ音樂歌詞AndroidView
- Android開源音樂播放器之高仿雲音樂黑膠唱片Android播放器
- Android開發筆記(一百二十四)自定義相簿Android筆記
- Android開發筆記(一百一十八)自定義懸浮窗Android筆記
- Android進階:自定義視訊播放器開發(上)Android播放器
- Android進階:自定義視訊播放器開發(下)Android播放器
- Android自定義View--仿QQ音樂歌詞AndroidView
- Android開源音樂播放器之線上音樂列表自動載入更多Android播放器
- Android開源音樂播放器之播放器基本功能Android播放器
- Qt+MPlayer音樂播放器開發筆記(一):ubuntu上編譯MPlayer以及Demo演示QT播放器筆記Ubuntu編譯
- 音樂播放器播放器
- 基於 electron-vue 開發的音樂播放器Vue播放器
- 雲音樂vue開發日記Vue
- vue音樂播放器Vue播放器
- Android開發之自定義SpinnerAndroid
- Android開源音樂播放器之自動滾動歌詞Android播放器
- ios開發筆記--狀態列的自定義,隱藏iOS筆記
- Flex4/Flash開發線上音樂播放器 , 含演示地址Flex播放器
- TurnTable for Mac音樂播放器Mac播放器
- Listen 1音樂播放器播放器
- 千千音樂 for Mac(原百度音樂播放器)Mac播放器
- Android開發之自定義View(一)AndroidView
- Android開發之自定義View(二)AndroidView
- 直播軟體開發,Android自定義簡單的音訊波譜viewAndroid音訊View
- iOS開發筆記 | 自定義具有內邊距的labeliOS筆記
- Android開發筆記Android筆記
- H5音樂播放器H5播放器
- TurnTable for Mac(iTunes音樂播放器)Mac播放器
- Mac音樂播放器——TurnTable for MacMac播放器
- vue2.0音樂播放器Vue播放器
- 微信小程式:音樂播放器微信小程式播放器
- Android Studio NDK開發:自定義庫Android
- vue 音樂播放器學習筆記----vue+stylus樣式縮排問題Vue播放器筆記
- 怎麼修改電腦開機聲音?電腦開機音樂自定義設定修改的方法