Android Music Player
Q7nl1s admin

Android Music Player

介绍

概述

这是一个基于 Android 平台的音乐播放器,可以内置本地音乐文件。

技术实现

  • 使用 ExoPlayer 库进行音乐播放:ExoPlayer 是 Google 提供的一个开源媒体播放库,支持播放本地和网络媒体,拥有强大的扩展性和自定义性,被广泛应用于各种音视频播放场景。在该播放器中,通过创建 ExoPlayer 对象,加载本地媒体资源,并设置相关参数实现音乐播放功能。

  • AssetManager - 该类使用 AssetManager 来获取 assets 文件夹下的音乐文件。

  • 悬浮窗口 - 该类使用了悬浮窗口来展示欢迎信息,以及在用户授权悬浮窗权限后,展示浮窗。

  • WindowManager - 该类使用 WindowManager 来管理悬浮窗口。

  • 权限管理 - 该类使用了 Android 的权限管理机制,检查是否有悬浮窗权限并请求授权,处理请求授权的结果。

  • Handler - 该类使用了 Handler 来延迟移除悬浮窗口。

  • Intent - 该类使用了 Intent 来启动另一个 Activity。

  • ArrayAdapter - 该类使用了 ArrayAdapter 适配器来为 ListView 设置数据源。

  • 使用 Timer 类定时更新进度条:Timer 是 Java 提供的一个定时任务类,用于实现周期性任务的调度,通过创建 Timer 对象,指定定时任务执行的时间间隔和任务具体内容,来实现音乐播放进度条的更新。

  • 使用 Animator 实现唱片封面旋转:Animator 是 Android 提供的一个动画类,用于实现复杂的动画效果,包括属性动画和帧动画。在该播放器中,通过创建 Animator 对象,指定动画执行的时间和旋转角度等参数,来实现唱片封面的旋转效果。

  • 使用 AssetManager 类访问 Assets 目录下的资源:AssetManager 是 Android 提供的一个管理 Asset 资源的类,用于访问 Assets 目录下的资源,包括图片、音频、视频等。在该播放器中,通过创建 AssetManager 对象,加载 Assets 目录下的图片资源,并转换为 Bitmap 格式的图片,用于显示唱片封面。

  • 使用 SeekBar 控件实现拖动进度条:SeekBar 是 Android 提供的一个滑动条控件,用于实现用户拖动进度条的功能。在该播放器中,通过创建 SeekBar 对象,设置最大值、当前进度和监听器等参数,来实现用户拖动进度条后音乐播放进度的改变。

  • 使用 Button 控件实现播放、暂停、停止、上一首、下一首等功能:Button 是 Android 提供的一个按钮控件,用于实现用户点击操作的功能。在该播放器中,通过创建 Button 对象,设置不同的标识符和监听器等参数,来实现不同的操作功能。

  • 多线程的应用。在这个音乐播放器中,我们使用了 Java 提供的 Timer 和 TimerTask 类来实现定时任务,从而实现歌曲播放进度的自动更新。

    Timer 和 TimerTask 是 Java 中提供的定时任务相关的类,通过它们我们可以实现在指定的时间间隔内进行重复的定时任务。在这个音乐播放器中,我们使用 Timer 和 TimerTask 来实现每隔一段时间更新 SeekBar 进度条的功能。具体而言,我们通过在 TimerTask 的 run() 方法中不断获取当前播放进度并更新 SeekBar 的进度值,从而实现 SeekBar 的实时更新。

成品展示

image-20230403151434812image-20230403151504857

功能模块

MusicListViewActivity

音乐列表管理界面

类的私有变量

1
2
3
4
private ListView listView;	
private ArrayAdapter<String> adapter; // 适配器
private ArrayList<String> musicList = new ArrayList<>(); // 存放歌曲资源名,格式 歌名-歌手.mp3 ,存放的内容和 ListMusicActivity 中的 musicList 完全一样
private static final int OVERLAY_PERMISSION_REQUEST_CODE = 100; // 响应悬浮窗权限请求的常量

音乐列表加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void initListView() throws IOException {
listView = (ListView) findViewById(R.id.lv_music);
// 使用 AssetManager 类来获取 assets/music 文件夹下的所有音乐文件名,并将它们添加到 ArrayList 中。
AssetManager assetManager = getAssets();
String[] files = assetManager.list("music");
for (String file : files) {
if (file.endsWith(".mp3")) {
musicList.add(file);
}
}

// 获取 ListView 组件,并为其设置一个 ArrayAdapter 适配器,以显示 ArrayList 中的音乐文件名
adapter = new ArrayAdapter<String>(
MusicListViewActivity.this,
android.R.layout.simple_list_item_1,
musicList
);
listView.setAdapter(adapter);
listView.setChoiceMode( ListView.CHOICE_MODE_SINGLE ); // 单选模式
listView.setOnItemClickListener(listview_listener); // 设置监听器
}

initListView() 方法中,程序会扫描 music 目录下的所有音乐文件,并将其存储到 musicList 集合中。在加载音乐列表时,程序会将 musicList 中的音乐文件名显示在界面上。其中adapter 是一个String 类型的适配器,用于帮助 UI 组件填充数据的中间桥梁(在本例中 UI 组件为 ListView),同样的,它也是 MVC 体系结构中的一种。

image-20230321165101660

ArrayAdapter:支持泛型操作,最为简单,只能展示一行字。

为 listview 配置了适配器后还通过 setChoiceMode 方法设置选择模式,有

◼ 多选:ListView.CHOICE_MODE_MULTIPLE
◼ 单选:ListView.CHOICE_MODE_SINGLE(默认)

两种。

在这之后通过设置监听其,来监听用户的点击操作。

1
2
3
4
5
6
7
8
9
10
11
// ListView监听器
AdapterView.OnItemClickListener listview_listener = new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Intent intent = new Intent();
intent.putExtra("currentlistmusic",position);
Log.d("current", ""+position);
intent.setClass(MusicListViewActivity.this, ListMusicActivity.class);
startActivity(intent);
}
};

为每个音乐文件添加点击事件,点击事件会将相应的音乐文件在播放器中播放。具体实现的是 ListView 的点击事件监听器,当用户点击 ListView 的某一项时,会跳转到 ListMusicActivity,并将点击的位置信息传递给这个 ListMusicActivity ,以便在 ListMusicActivity 中定位到相应位置进行播放。

Snipaste_2023-04-03_15-37-38image-20230403153854035

启动MusicListViewActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_music_list_view);
// 获取 ActionBar 对象并隐藏
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.hide();
}
try {
initListView();
} catch (IOException e) {
throw new RuntimeException(e);
}


// 检查是否有悬浮窗权限,如果没有则请求权限
if (!Settings.canDrawOverlays(this)) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getPackageName()));
startActivityForResult(intent, OVERLAY_PERMISSION_REQUEST_CODE);
} else {
// 如果已经有悬浮窗权限,则显示浮窗
showFloatingWindow();
}
}

在 onCreate 启动方法中线判断 actionBar 是否存在,如果存在的话则通过 actionBar.hide() 来将 actionBar 隐藏,并进行初始化视图

注:由于 initListView 中抛出了 IOException 所以要用 try-catch 进行捕获

权限查询:!Settings.canDrawOverlays(this) 用于判断当前程序是否声明了声明了 Manifest.permission ,如果 SYSTEM_ALERT_WINDOW 权限在它的清单中,用户明确地授予应用程序这个能力,为了提示用户授予该批准,应用程序必须发送带有 ACTION_MANAGE_OVERLAY_PERMISSION 动作的 Intent 通信来打开系统设置界面,这将导致系统显示权限管理屏幕。

具体来说,这里使用了Settings.canDrawOverlays()方法来检查是否具有悬浮窗权限。如果该方法返回false,则说明没有悬浮窗权限,需要请求权限。在这种情况下,会创建一个Intent对象,其中包含了请求授权的ACTION_MANAGE_OVERLAY_PERMISSION action,同时将package:数据设置为当前应用程序的包名,这样就可以跳转到应用程序的悬浮窗权限设置界面。

当用户完成悬浮窗权限的授权后,会自动返回到该应用,并调用onActivityResult()方法。在这个方法中,可以根据requestCode参数来判断这个结果是从哪个Activity返回的。如果是悬浮窗权限设置界面返回的结果,那么就再次检查是否拥有悬浮窗权限。如果此时已经具有悬浮窗权限,那么就可以显示悬浮窗。

注:startActivityForResult 函数现已经被弃用

处理请求悬浮窗口权限的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 处理请求悬浮窗权限的结果
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == OVERLAY_PERMISSION_REQUEST_CODE) {
if (Settings.canDrawOverlays(this)) {
// 如果用户授权了悬浮窗权限,则显示浮窗
showFloatingWindow();
} else {
// 如果用户拒绝了悬浮窗权限,则关闭应用
finish();
}
}
}

这是一个回调函数,在这个回调函数中,首先通过比较参数requestCode和常量OVERLAY_PERMISSION_REQUEST_CODE的值来确定这个回调函数是响应悬浮窗权限请求的结果。如果两者相等,就表示是响应了悬浮窗权限请求的结果。

接着,该代码判断是否授权了悬浮窗权限。如果用户授权了悬浮窗权限,则调用showFloatingWindow()方法显示浮窗。如果用户没有授权悬浮窗权限,则调用finish()方法关闭应用程序。

需要注意的是,由于showFloatingWindow()方法和finish()方法都是在回调函数中执行的,所以它们都运行在 UI 线程中,如果执行时间过长,可能会造成 ANR(应用程序未响应)。因此,在实际使用中,应该避免在回调函数中执行耗时操作,而应该使用异步任务或线程池等方式来执行。

image-20230404121602541image-20230404121658479

显示悬浮窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 显示浮窗
private void showFloatingWindow() {
// 创建悬浮窗口的布局参数
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O // 可以不写该语句,because DK_INT is always >= 29
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT);

// 设置悬浮窗口的位置和大小
params.gravity = Gravity.CENTER; // 指定控件在父布局中的水平和垂直方向上都居中对齐
params.width = WindowManager.LayoutParams.WRAP_CONTENT; // 控件的宽度和高度会根据控件内部内容的大小来自动调整,以保证内容的全部显示
params.height = WindowManager.LayoutParams.WRAP_CONTENT;

// 创建悬浮窗口的View
TextView floatingView = new TextView(this);
floatingView.setText("欢迎来到音乐的世界(●'◡'●)");
floatingView.setTextColor(Color.LTGRAY);
floatingView.setTextSize(20);
floatingView.setBackgroundColor(Color.WHITE);

// 获取WindowManager服务并添加悬浮窗口
WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
windowManager.addView(floatingView, params);

// 延迟三秒后移除悬浮窗口
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
windowManager.removeView(floatingView);
}
}, 3000);
}

该方法会创建一个WindowManager.LayoutParams对象来设置悬浮窗口的布局参数,包括悬浮窗口的大小、位置、类型和属性等。其中,如果系统的 SDK 版本大于等于 26,则使用TYPE_APPLICATION_OVERLAY类型,否则使用TYPE_PHONE类型。

Build.VERSION.SDK_INT:当前系统的 SDK
Build.VERSION_CODES.O:是一个 Android 系统的版本号常量,表示 Android 8.0(API Level 26)的版本号。

然后,创建一个TextView对象作为悬浮窗口的视图,设置了悬浮窗口的文字、字体颜色和背景颜色等属性。

接着,通过WindowManager服务来添加悬浮窗口,将悬浮窗口的视图对象和布局参数传入windowManager.addView()方法中,即可将悬浮窗口添加到应用程序的窗口中。

最后,使用Handler延迟三秒后再移除悬浮窗口,这里是通过windowManager.removeView()方法来移除视图对象,从而移除悬浮窗口。

需要注意的是,在移除悬浮窗口时,要使用WindowManager服务的实例对象来进行移除,而不是使用之前的局部变量floatingView。因为floatingView是视图对象,而不是窗口对象,如果使用floatingView.remove()方法来移除悬浮窗口,是无效的。

image-20230404120454094

Intent 通信

MusicListViewActivity

1
2
3
4
5
6
7
8
9
10
AdapterView.OnItemClickListener listview_listener = new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Intent intent = new Intent();
intent.putExtra("currentlistmusic",position);
Log.d("current", ""+position);
intent.setClass(MusicListViewActivity.this, ListMusicActivity.class);
startActivity(intent);
}
};

在 MusicListViewActivity 中我们通过设置 ListView 的监听器,实现在用户选择歌曲时借助 Intent 通信,将当前歌曲在 musicList 中的下标 position 发送至 ListMusicActivity 中,由于在 ListMusicActivity 中装载了一个一摸一样的 musicList, 所以可以在播放时实现同步定位至用户点击的音乐。

ListMusicActivity

1
2
3
4
public void initintent(){
Intent intent = getIntent();
currentMusicIndex = intent.getIntExtra("currentlistmusic", 0); // 0 为默认值,如果获取失败,返回 0
}

initialMusicIndex 用于存放开始播放时初始音乐的下标,实际上就是从 MusicListViewActivity 中传输过来的下标值

ListMusicActivity

音乐播放器界面

类的私有变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private ArrayList<String> musicList = new ArrayList<>();    // 存放歌曲资源名,格式 歌名-歌手.mp3 ,存放的内容和 MusicListViewActivity 中的 musicList 完全一样
private ArrayList<String> artistList = new ArrayList<>(); // 保存歌手
private ArrayList<String> titleList = new ArrayList<>(); // 保存歌曲名

private SeekBar seekBar; // 歌曲进度条
private SeekBar volumeSeekbar; // 音量seekbar

private TextView seekBarHint; //音乐时长提示
private TextView songTotal; // 歌曲总时长
private TextView play_song_name; // 显示歌名
private TextView play_song_artist; // 显示歌手名字

private ImageView album_cover; // 唱片封面
private ImageView voluneMute; // 静音按键
private ImageView voluneMax; // 最大音量

private Button play_go_back; // 返回按钮
private ImageButton btn_player_pattern; // 切换模式按钮
private ImageButton btn_player_quit; //退出按钮
private ImageButton btn_player_previous; //上一首按钮
private ImageButton btn_player_ps; //播放暂停按钮
private ImageButton btn_player_next; //下一首按钮

private ExoPlayer player; //播放器
private Timer timer; //定时器
private MediaItem mediaItem; // 用于解析 Uri 资源文件

private boolean isPlaying = false; // 当前是否正在播放
private boolean prepared; //播放器是否准备好

private int initialMusicIndex; // 初始播放的音乐
private int currentIndex; // 当前播放歌曲的下标
private int pattern; // 1:列表循环-loop_play.png 2:单曲循环-loopone_play.png 3:随机播放-shuffle_play.png

private ContentObserver volumeObserver; // 音量监听器
private ObjectAnimator mAnimator; // 用于创建动画(图片旋转)

入口函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); // 隐藏 Statues Bar
setContentView(R.layout.activity_list_music);
// 获取 ActionBar 对象并隐藏
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.hide();
}
try {
initData();
} catch (Exception e) {
throw new RuntimeException(e);
}
prepared = false; //初始值
initExoPlayer();
initAnimator(album_cover); // 图片旋转
//8个监听
player.addListener(listener1);
play_go_back.setOnClickListener(button_listener);
btn_player_quit.setOnClickListener(button_listener);
btn_player_previous.setOnClickListener(button_listener);
btn_player_ps.setOnClickListener(button_listener);
btn_player_next.setOnClickListener(button_listener);
btn_player_pattern.setOnClickListener(button_listener);
seekBar.setOnSeekBarChangeListener(listener3);
} //end create

在入口函数中先隐藏 Statues Bar,然后设置当前 Activity 的页面资源 id,并获取 Action Bar 对象,如果存在的话也对其进行隐藏。接下来进行各种初始化:

  • initData()->对必要的变量和对象进行注册和初始化
    1. 注册 activity_list_music.xml 中用到的有交互或动态改变的的视图对象
    2. initintent():用于 activity 间的信息传输,此处是对 initialMusicIndex 的初始化
    3. initArray():用于初始化 musicList、artistList、titleList,分别对应歌曲文件名、歌手、歌曲名,它们在 ArrayList<String> 中按下标成映射关系
  • initExoPlayer()->创建播放器
    1. 创建 ExoPlayer 对象
    2. 通过 Uri 解析 "file:///android_asset/music/" + musicList.get(i) 向刚创建的 ExoPlayer 对象中添加数据
    3. 通过 player.prepare() 准备 ExoPlayer 对象,并触发监听器
      • 获取媒体文件的时长
      • 设置SeekBar最大值
      • 获取当前播放歌曲的下标赋值给 currentIndex
    4. 创建 Timer 定时器
    5. 启动的定时任务
      • 调用定时任务类 ProgressUpdate
      • 定时刷新SeekBar进度条
      • 动态设置当前播放的歌曲名、歌手和当前歌曲总时长
      • getAssetsMusicName()
        1. 通过 assetManager 的 list("pic") 方法获取 pic 文件夹下的图片资源文件保存在 filenames 变量中
        2. 对当前播放歌曲的文件名进行截取只保留前缀,去掉后缀->”歌名-歌手”
        3. 根据前缀对 filenames 变量中的 pic 文件进行匹配,如果有匹配到相应前缀则保存此图片名到变量 targetFilename
        4. 将该图片通过 assetManager.open("pic/" + targetFilename) 打开并转换为 InputStream 流
        5. 从输入流(InputStream)创建一个可绘制对象(Drawable)
        6. 强转为 BitmapDrawable 类型并通过 getBitmap() 方法将 Drawable 类型数据转化为 Bitmap 类型
        7. 通过 getCircleBitmap 函数转换为圆形图片
        8. 为 ImageView 视图对象 album_cover 设置 setImageBitmap 函数来将 Bitmap 类型数据设置到 ImageView 中
        9. 模式匹配,对随机模式下的情况进行特殊处理(当音乐进入最后 0.5 秒时切换下一首)
  • initAnimator(album_cover)->设定旋转动画样式
  • 配置监听器->8 个对象对应 8 种监听事件
    1. player:ExoPlayer 播放器
    2. play_go_back:返回上级 Activity 按钮
    3. btn_player_quit:结束播放音乐按键
    4. btn_player_previous:上一首歌按键
    5. btn_player_ps:停止播放按键和开始播放按键
    6. btn_player_next:下一首歌按键
    7. btn_player_pattern:切换模式按键
    8. seekBar:seekBar 用户滑动进度条监听

image-20230404214248355

从 assets 目录中读取音乐文件列表和对应的图片,存储在 musicList 和 picList 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 动态填充数据
public void initArray() throws IOException {
// 使用 AssetManager 类来获取 assets/music 文件夹下的所有音乐文件名,并将它们添加到 ArrayList 中。
AssetManager assetManager = getAssets();
String[] files = assetManager.list("music");
for (String file : files) {
if (file.endsWith(".mp3")) {
// 将歌曲文件名存入 musicList
musicList.add(file);
// 将歌曲名解析存为歌曲名和歌手
// 正则表达式匹配歌手和歌名信息
Pattern pattern = Pattern.compile("(.+)-(.+)\\..+");
Matcher matcher = pattern.matcher(file);
if (matcher.matches()) {
String singer = matcher.group(1);
String song = matcher.group(2);
artistList.add(singer);
titleList.add(song);
}
}
}
}

初始化 musicList、artistList、titleList,分别对应歌曲文件名、歌手、歌曲名,它们在 ArrayList<String> 中按下标成映射关系

具体的动态地从应用的 assets 目录下的 music 子目录中读取所有以 “.mp3” 结尾的音乐文件名,并将其存储在 files 中,使用一个 for 循环遍历这些文件名,并通过正则表达式匹配获取该文件名中的歌手名和歌曲名,并将它们分别添加到 artistListtitleList 中。

便于后期处理播放音乐时歌手和歌曲名的同步显示

image-20230404171402622

初始化 ExoPlayer,设置播放列表,设置循环模式,启动定时任务,监听播放器状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void initExoPlayer() {
// 创建播放器
// import com.google.android.exoplayer2.MediaItem;
player = new ExoPlayer.Builder(this).build();
for(int i = 0; i < musicList.size(); i++){
Uri uri = Uri.parse("file:///android_asset/music/" + musicList.get(i));
mediaItem = MediaItem.fromUri(uri);
player.addMediaItem(mediaItem);//加载媒体资源
}
player.seekTo(initialMusicIndex,0);
// Log.d("initialMusicIndex", ""+initialMusicIndex);
player.setRepeatMode(ExoPlayer.REPEAT_MODE_ALL); // 设置列表循环模式
player.prepare();//会触发下面定义的监听器
player.setPlayWhenReady(true);
isPlaying = true;
timer = new Timer();
// 启动定时任务
timer.schedule(new ProgressUpdate(), 0, 500);
}
  • 首先通过 ExoPlayer.Builder 创建一个新的 ExoPlayer 实例
  • 遍历 musicList 列表,通过 Uri 解析 "file:///android_asset/music/" + musicList.get(i) 并依次将每个音乐文件转化为 MediaItem 对象,并通过 player.addMediaItem 方法加载到播放器中
  • 设置播放器的列表循环模式为默认的全部循环
  • 通过 player.prepare() 方法准备播放器,此处会触发下面定义的监听器
  • 通过 player.setPlayWhenReady(true) 方法启动播放器,并将 isPlaying 标志设置为 true
  • 启动一个 Timer 定时任务,每隔 500 毫秒更新音乐播放进度,其中 ProgressUpdate 是一个内部类,实现了 TimerTask 接口;
  • 最后,该方法会在初始化完成后自动触发 ExoPlayer 的 onPlayerStateChanged 监听器,可以在该监听器中对播放器的状态进行处理。

相应的监听器为

1
2
3
4
5
6
7
8
9
10
11
12
13
// 播放器的监听器
Player.Listener listener1 = new Player.Listener() {
@Override
//播放器状态监听器
public void onPlaybackStateChanged(int playbackState) {
if (playbackState == ExoPlayer.STATE_READY) { //播放器准备好了
prepared = true;
long realDurationMillis = player.getDuration(); // 获取媒体文件的时长(毫秒)
seekBar.setMax((int) realDurationMillis); // 设置SeekBar最大值
currentIndex = player.getCurrentMediaItemIndex(); // 获取当前播放歌曲的下标
}
}
};

此处定义了一个播放器的监听器对象 listener1,并且实现了 Player.Listener 接口。

其中 onPlaybackStateChanged() 方法是播放器状态变化的回调方法,当播放器状态变化时会被调用。在该方法中,如果播放器的状态为 ExoPlayer.STATE_READY(播放器准备好了),就会将 prepared 设置为 true,获取媒体文件的时长(毫秒),并将 SeekBar 的最大值设置为该时长。

同时,还会获取当前播放歌曲的下标给到变量 currentIndex

==重要提醒==

使用 MediaPlayer 的 setPlayWhenReadyprepare 方法有需要注意的几点:

  • setPlayWhenReady 控制播放状态,而 prepare 准备播放资源。

    • setPlayWhenReady(boolean playWhenReady) 方法用于控制媒体播放器的播放状态。当 playWhenReady 参数为 true 时,媒体播放器会自动开始播放。当 playWhenReady 参数为 false 时,媒体播放器会暂停播放。这个方法的作用是设置媒体播放器的播放状态,而不是实际开始或暂停播放。

    • prepare() 方法用于准备媒体播放器的播放资源。在调用这个方法之前,你需要设置媒体播放器的数据源和一些其他的参数。调用 prepare() 方法后,媒体播放器会准备资源并进入准备就绪状态,可以随时开始播放。

  • 触发的监听器不同

    • setPlayWhenReady 方法会触发 onPlaybackStateChanged 监听器,用于监听媒体播放器的状态变化。当媒体播放器开始播放时,状态将从 STATE_IDLE 变为 STATE_BUFFERING,再变为 STATE_READY。当媒体播放器停止播放时,状态将变为 STATE_IDLE
    • prepare 方法会触发 OnPreparedListener 监听器,用于监听媒体播放器是否已经准备好了播放资源。当媒体播放器完成资源准备后,将自动调用该监听器的 onPrepared 方法,表示媒体播放器已经准备好开始播放。

播放按钮和播放模式监听的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private View.OnClickListener button_listener = new View.OnClickListener() {    // 匿名内部类实现事件监听的另一种写法
@SuppressLint("NonConstantResourceId")
@Override
public void onClick(View view) {
switch ( view.getId() ) {
case R.id.patternButton:
patternButtonClickfunc();
break;
case R.id.quitButton:
quitButtonClickfunc();
break;
case R.id.previousButton:
previousButtonClickfunc();
break;
case R.id.nextButton:
nextButtonClickfunc();
break;
case R.id.psButton:
psButtonClickfunc();
break;
case R.id.play_go_back:
play_go_backClickfunc();
break;
}
}
};

定义了一个匿名内部类实现了一个 View.OnClickListener 接口,用于实现多个按钮的点击事件监听器。这个监听器包含一个 onClick() 方法,在其中通过 switch-case 语句来判断是哪个按钮被点击,并调用相应的方法来处理这个点击事件。每个按钮的点击事件处理方法在其他部分的代码中定义。这样的写法可以避免创建多个单独的监听器对象,提高了代码的可读性和维护性。

返回按钮

1
2
3
4
5
public void play_go_backClickfunc(){
Intent intent = new Intent(ListMusicActivity.this, MusicListViewActivity.class);
setResult(RESULT_OK, intent);
finish();
}

当该按钮被点击时,会创建一个新的 Intent 对象,实现 Activity 之间的跳转,RESULT_OK 表示操作成功完成。

最后,调用 finish() 方法关闭当前播放界面,返回到列表界面。

image-20230404173258890

模式切换按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void patternButtonClickfunc(){
// Log.d("flag", "patternButtonClickfunc");
// 点击后进入下一个模式
if(pattern == 1){ //列表循环
pattern = 2;
btn_player_pattern.setImageResource(R.drawable.loopone_play);
player.setRepeatMode(ExoPlayer.REPEAT_MODE_ONE);// 设置单曲循环模式
}else if(pattern == 2){ //单曲循环
pattern = 3;
btn_player_pattern.setImageResource(R.drawable.shuffle_play);
player.setRepeatMode(ExoPlayer.REPEAT_MODE_OFF);// 正常播放无循环
}else if(pattern == 3){ // 随机播放
pattern = 1;
btn_player_pattern.setImageResource(R.drawable.loop_play);
player.setRepeatMode(ExoPlayer.REPEAT_MODE_ALL); // 设置列表循环模式
}
}

该部分实现了播放器的播放模式切换功能。点击按钮后,根据当前的播放模式切换到下一个模式,并设置对应的循环模式。具体来说,一共有三种模式:

  1. 列表循环模式:播放完整个音乐列表后循环播放,对应的循环模式为 ExoPlayer.REPEAT_MODE_ALL
  2. 单曲循环模式:循环播放当前歌曲,对应的循环模式为 ExoPlayer.REPEAT_MODE_ONE
  3. 随机播放模式:随机播放音乐列表中的歌曲,对应的循环模式为 ExoPlayer.REPEAT_MODE_OFF->表现在一次列表播放结束后停止。

注:此处 setRepeatMode 设置 ExoPlayer.REPEAT_MODE_OFF 的原因是防止受到另外两个模式的影响,由于 ExoPlayer 没有内置随机模式,所以需要自己实现

具体实现表现在:seekar 监听器、下一首按钮的监听器、上一首按钮的监听器

取消播放按钮

1
2
3
4
5
6
7
8
9
10
11
12
public void quitButtonClickfunc(){
player.seekTo(0L);
new ProgressUpdate().run(); // 执行最后一次更新进度条
isPlaying = false;
mAnimator.pause();//动画暂停
player.setPlayWhenReady(false);
timer.cancel(); //停止定时器
timer = new Timer(); //新建定时器
btn_player_ps.setImageResource(R.drawable.play_song);
songTotal.setText("--:--");
seekBarHint.setText("");
}

注:为这个监听器以及下面多个监听器加上语句:timer.cancel(); //停止定时器 是必要的,这是为了前一个 timer 未被正确取消而产生的并发问题,可能会导致 songTotal 和 seekBarHint的显示出现问题。

为对 quitButton 的理解是停止播放当前歌曲,并将 seekbar 的值回到最开始处,将 seekbartint 显示为空,songTotal 设置为 –:–,并且要停止当前动画重建定时器,并将 btn_player_ps 图标换成 R.drawable.play_song,这样就可以实现按键交互。

image-20230404174501680image-20230404174636378

上一首按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void previousButtonClickfunc(){
btn_player_ps.setImageResource(R.drawable.stop_song);
// 分为两种状态:随机模式和其它模式,随机模式的时候对列表内的歌曲进行随机播放
int t = 0;
if(pattern == 3){
Random r = new Random();
t = r.nextInt(musicList.size());
}else{
if(currentIndex == 0){
t = musicList.size()-1;
}else{
t = currentIndex-1;
}
}
player.seekTo(t,0);
player.prepare();
player.setPlayWhenReady(true);
isPlaying = true;
timer.cancel(); //停止定时器
timer = new Timer();
// 启动定时任务
timer.schedule(new ProgressUpdate(), 0, 500);
}

该函数是响应上一首按钮点击事件的函数。根据播放模式,确定要播放的歌曲。

如果是随机模式,就从音乐列表中随机选择一首歌曲。否则,如果当前歌曲不是第一首,则播放上一首歌曲;否则,播放列表中的最后一首歌曲。

注: player.seekTo(t,0); 函数说明

seekTo()函数第一个参数表示,要跳转到的音乐在 ExoPlayer 当前资源列表中的下标,第二个参数表示,该资源的播放位置。

设置播放器的播放位置为选择的歌曲,并准备播放。

最后,启动一个定时器来更新播放进度条。

暂停和开始播放按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void psButtonClickfunc(){
if (isPlaying) { // 是否在播放
player.setPlayWhenReady(false);
isPlaying = false;
mAnimator.pause();//动画暂停
timer.cancel(); //停止定时器
timer = new Timer(); //新建定时器
btn_player_ps.setImageResource(R.drawable.play_song);
} else {
player.setPlayWhenReady(true);
isPlaying = true;
mAnimator.start();//动画开始
timer.cancel(); //停止定时器
timer = new Timer();
// 启动定时任务
timer.schedule(new ProgressUpdate(), 0, 500);
btn_player_ps.setImageResource(R.drawable.stop_song);
}
}

实现了播放/暂停按钮的点击事件处理函数。当点击播放/暂停按钮时,该函数首先判断当前是否正在播放音乐,如果是,则暂停播放并暂停相关的动画效果和定时器任务;如果不是,则恢复播放并恢复动画效果和定时器任务。

同时,该函数还会根据当前播放状态更新播放/暂停按钮的图标。其中,动画效果是通过 mAnimator 变量实现的。

image-20230404175912732image-20230404180031278

下一首按钮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void nextButtonClickfunc(){
btn_player_ps.setImageResource(R.drawable.stop_song);
// 分为两种状态:随机模式和其它模式,随机模式的时候对列表内的歌曲进行随机播放
int t;
if(pattern == 3) {
Random r = new Random();
t = r.nextInt(musicList.size());
}else{
System.out.println(currentIndex);
if(currentIndex == musicList.size()-1){
t = 0;
}else{
t = currentIndex+1;
}
}
player.seekTo(t,0);
player.prepare();
player.setPlayWhenReady(true);
isPlaying = true;
timer.cancel(); //停止定时器
timer = new Timer();
// 启动定时任务
timer.schedule(new ProgressUpdate(), 0, 500);
}

和上一首按钮类似

如果是随机模式,就从音乐列表中随机选择一首歌曲。否则,如果当前歌曲不是最后一首,则播放下一首歌曲;否则,播放列表中的第一首歌曲。

设置播放器的播放位置为选择的歌曲,并准备播放。

最后,启动一个定时器来更新播放进度条。

进度条控制当前音乐播放进度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SeekBar的监听器
SeekBar.OnSeekBarChangeListener listener3 =
new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress,
boolean fromUser) {
if (prepared && fromUser) {
player.seekTo(progress);
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
seekBarHint.setText(format(seekBar.getProgress()));
}
};

此处是一个 SeekBar 的监听器,它实现了 SeekBar.OnSeekBarChangeListener 接口。

在用户拖动 SeekBar 时,会触发 onProgressChanged() 方法。如果 MediaPlayer 已经准备好,并且变化是由用户拖动而不是程序更改引起的,那么就会调用 player.seekTo() 方法,将 MediaPlayer 的播放位置设置为 SeekBar 的当前进度。

在用户停止拖动 SeekBar 时,会调用 onStopTrackingTouch() 方法,设置 SeekBar 提示文本为当前进度的格式化字符串。

在本监听器中,onStartTrackingTouch() 方法没有实现任何操作。

定时器类和器任务的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 定时任务类:定时刷新SeekBar进度条,并且动态设置当前播放歌曲名和当前歌曲总时长
private class ProgressUpdate extends TimerTask {
@Override
public void run() {
runOnUiThread( new Runnable() {
@Override
public void run() {
// 获取媒体播放的当前位置(毫秒)
long position = player.getContentPosition();
// Log.d("flag", "pos=" + position);
// SeekBar设置进度
seekBar.setProgress((int) position);
// 显示当前音乐时间
seekBarHint.setText( format(position) );
songTotal.setText(format(player.getDuration()));
play_song_name.setText(titleList.get(player.getCurrentMediaItemIndex()));
play_song_artist.setText(artistList.get(player.getCurrentMediaItemIndex()));
try {
getAssetsMusicName(); // 通过文件名匹配后缀,并注册圆形图片
} catch (IOException e) {
throw new RuntimeException(e);
}
if(pattern == 3 && seekBar.getMax()-500 <= position ){ // 当音乐进入最后 0.2 秒时切换下一首
int t = 0;
Random r = new Random();
t = r.nextInt(musicList.size());
player.seekTo(t,0);
player.prepare();
player.setPlayWhenReady(true);
isPlaying = true;
timer = new Timer();
// 启动定时任务
timer.schedule(new ProgressUpdate(), 0, 500);
}
}
});
}
}

这是一个内部类 ProgressUpdate,继承自 TimerTask,用于在定时器任务中更新音乐播放器的进度条和当前播放歌曲的信息。

其中 format() 是一个自定义函数其主要实现将音乐毫秒数转为”分:秒”格式显示

其具体代码如下:

1
2
3
4
5
6
7
// 将音乐毫秒数转为"分:秒"格式显示
// import java.text.SimpleDateFormat;
public String format(long position) {
SimpleDateFormat sdf = new SimpleDateFormat("mm:ss"); // "分:秒"格式
String timeStr = sdf.format(position); //会自动将时长(毫秒数)转换为分秒格式
return timeStr;
}

run 方法是 TimerTask 类中的一个抽象方法,会在定时器到达指定时间后被调用。在 run 方法中,通过调用 runOnUiThread 方法,可以在 UI 线程中更新界面元素。

在这个方法中,首先获取当前音乐播放的位置,将其设置为 SeekBar 的进度条的当前位置。

同时,也将当前歌曲的播放时间和歌曲总时长显示出来,更新歌曲名和歌手名的文本。

最后,判断当前播放模式是否为随机模式,如果是,当音乐进入最后 0.2 秒时,切换到下一首随机歌曲进行播放。如果不是,那么就会按照列表循环模式进行播放。

注:这边取 200 毫秒是有原因的,由于 ProgressUpdate 每次的执行周期是 0.5 秒,如果设置的太小会监听不到,还有一点需要注意的是,此处不能添加 position <= seekBar.getMax(),因为 position 是有可能比 seekBar.getMax(),因为对 seekBar 的赋值语句 seekBar.setProgress((int) position) 本就是截断式的。

getAssetsMusicName() 函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void getAssetsMusicName() throws IOException {
AssetManager assetManager = getAssets();
String[] filenames = assetManager.list("pic");
// 对文件名进行截取,只保留前缀
String targetPrefix = musicList.get(player.getCurrentMediaItemIndex()).substring(0, musicList.get(player.getCurrentMediaItemIndex()).lastIndexOf(".")); // 指定的文件名前缀
String targetFilename = null; // 最终找到的目标文件名
for (String filename : filenames) {
if (filename.startsWith(targetPrefix)) { // 如果文件名以指定前缀开头
int dotIndex = filename.lastIndexOf(".");
if (dotIndex != -1) {
targetFilename = filename;
break;
}
}
}
if (targetFilename != null) {
InputStream inputStream = assetManager.open("pic/" + targetFilename);
Drawable drawable = Drawable.createFromStream(inputStream, null);
Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap(); // 将Drawable类型数据转化为Bitmap类型
bitmap = getCircleBitmap(bitmap); // 转换为圆形图片
album_cover.setImageBitmap(bitmap); // 将Bitmap类型数据设置到ImageView中
}
}

它主要实现的是从 assert/pic 文件夹下找到当前歌曲的专辑封面图片转换为圆形

  1. 首先获取 AssetManager 对象,通过该对象可以访问 assets 文件夹中的资源。
  2. 调用 assetManager.list("pic") 方法获取 assets 文件夹下的所有文件名。
  3. 通过当前正在播放的歌曲在歌曲列表中的索引,获取该歌曲的文件名前缀,即去掉扩展名的部分。
  4. 在文件名列表中查找以该前缀开头的文件名,如果找到,就将其读取为 Bitmap 类型的图片,并将其转换为圆形图片。
  5. 最后将圆形图片设置到专辑封面的 ImageView 控件中。

image-20230404181705526陈婧霏-晚风

上述的第二张是原来的专辑图片

getCircleBitmap(bitmap)的具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 转化为圆形图片
public static Bitmap getCircleBitmap(Bitmap bitmap) {
Bitmap output = Bitmap.createBitmap(bitmap.getWidth(),
bitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);

Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setColor(Color.WHITE);

Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
RectF rectF = new RectF(rect);

float radius = bitmap.getWidth() / 2f;
canvas.drawCircle(radius, radius, radius, paint);

paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

canvas.drawBitmap(bitmap, rect, rectF, paint);

return output;
}

该方法用于将传入的 Bitmap 对象转换为圆形图片。

具体实现方法是通过创建一个与传入 Bitmap 相同大小的空白 Bitmap 对象,再创建一个 Canvas 对象来将原始图片绘制到这个空白 Bitmap 上,然后通过画笔的设置和操作来将绘制好的图片转换为圆形,并将其作为方法的返回值。具体步骤如下:

  1. 创建一个空白的 ARGB_8888 格式的 Bitmap 对象 output,大小与传入的 bitmap 相同。
  2. 创建一个 Canvas 对象 canvas,并将其绑定到 output 上。
  3. 创建一个 Paint 对象 paint,设置其抗锯齿属性为 true,颜色为白色。
  4. 创建一个 Rect 对象 rect,用于表示原始图片的大小范围。
  5. 创建一个 RectF 对象 rectF,用于表示圆形区域的大小范围,与 rect 相同。
  6. 计算出圆形区域的半径 radius,通过 drawCircle 方法在 canvas 上绘制一个白色的圆形区域。
  7. 设置画笔的 Xfermode 属性为 SRC_IN,通过 drawBitmap 方法将原始图片绘制到圆形区域中。
  8. 将绘制好的 output 作为方法的返回值,即可得到转换为圆形的 Bitmap 对象。

实现seekbar控制系统音量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 实现seekbar控制系统音量
public void initVolume(){
//实现seekbar控制系统音量
AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
volumeSeekbar.setMax(15);
volumeSeekbar.setProgress(audioManager.getStreamVolume((AudioManager.STREAM_MUSIC)));

//注册同步更新广播
myRegisterReceiver();
volumeSeekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
audioManager.setStreamVolume(AudioManager.STREAM_SYSTEM, i, 0);
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, i, 0);
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {}
});
volumeObserver = new ContentObserver(new Handler()) {
@Override
public void onChange(boolean selfChange) {
super.onChange(selfChange);
AudioManager audioManager1 = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
volumeSeekbar.setProgress(audioManager1.getStreamVolume(AudioManager.STREAM_MUSIC));
}
};
}

//实现seekbar系统音量控制
private void myRegisterReceiver(){
VolumeReceiver volumeReceiver = new VolumeReceiver();
IntentFilter filter = new IntentFilter();
filter.addAction("android.media.VOLUME_CHANGED_ACTION");
registerReceiver(volumeReceiver, filter);
}

public class VolumeReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if(intent.getAction().equals("android.media.VOLUME_CHANGED_ACTION")){
AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
int curVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
volumeSeekbar.setProgress(curVolume);
}
}
}

此处实现了一个 SeekBar 控制系统音量的功能,用于在 Android 设备上调节系统音量。

它使用了 AudioManager 类来进行音量的调节,该类是通过 Context 类的 getSystemService 方法获取的。

代码将 SeekBar 的最大值设置为 15,这是 Android 系统音量的最大值。这里还将 SeekBar 的初始进度设置为当前系统音量,使用了 AudioManager 类的 getStreamVolume 方法来实现。

当用户改变 SeekBar 的位置时,代码调用了 AudioManager 类的 setStreamVolume 方法来设置新的音量值。这个方法可以设置特定音频流的音量,这里设置了 STREAM_SYSTEM 和 STREAM_MUSIC 的音量。

此外,代码还注册了一个 BroadcastReceiver 来接收 VOLUME_CHANGED_ACTION Intent。每当系统音量发生变化时,这个 Intent 就会被广播。当接收到这个 Intent 时,代码会使用 AudioManager 类的 getStreamVolume 方法获取当前系统音量,并相应地更新 SeekBar 的进度。

最后,代码还定义了一个 ContentObserver 来监听系统音量的变化,并相应地更新 SeekBar 的进度。

image-20230404214514988image-20230404214648433

退出当前 Activity

1
2
3
4
5
6
@Override
protected void onDestroy() { // Activity结束前触发
super.onDestroy();
player.stop(); // 停止播放
player.release(); // 释放资源
}

在 Activity 结束前触发,停止 player 播放并释放资源。

总结

这个音乐播放器的开发使用了多项技术和框架,包括 Activity 编程、ListView 编程、SeekBar 编程、Intent 编程、Animator 编程、Handler 编程、WindowManager 编程以及ExoPlayer 编程等。通过它们的结合实现了一个功能丰富、用户体验良好的音乐播放器。

在 Activity 编程方面,该应用采用了两个个 Activity 实现不同的功能,包括歌曲列表 Activity 和播放器 Activity。播放器 Activity 包含了播放器的主要控件和功能以及音乐播放的详细界面和操作,歌曲列表 Activity 则提供了可供用户选择的歌曲列表。

在 ListView 编程方面,该应用使用了 ListView 控件展示了歌曲列表,用户可以通过点击列表中的歌曲选择要播放的歌曲。

在 SeekBar 编程方面,该应用使用 SeekBar 控件展示了音乐播放的进度,用户可以通过拖动 SeekBar 改变播放进度。并使用 volumeSeekbar 来调控系统音量,并使用监听器实现系统音量和 seekbar 进度条的同步改变。

在 Intent 编程方面,该应用使用了 Intent 传递数据,如在歌曲列表 Activity 中选择歌曲并跳转到播放器 Activity。

在 Animator 编程方面,该应用使用了 ObjectAnimator 实现了旋转动画效果,使得播放按钮在播放时能够以动画的形式旋转,增强了用户体验。

在 Handler 编程方面,该应用使用了 Handler 实现了更新 SeekBar 进度的功能,每隔一段时间就会通过 Handler 更新 SeekBar 的进度。

在 WindowManager 编程方面,该应用使用了 WindowManager 实现了悬浮窗功能,用户可以通过在主 Activity 中点击悬浮窗按钮将播放器悬浮在其他应用上方,方便用户同时进行其他操作。

在 ExoPlayer 编程方面,该应用使用了 ExoPlayer 实现了音乐的播放、暂停、停止、上一首、下一首等功能。ExoPlayer 是一个专门用于播放音频和视频的开源框架,具有稳定、高效、易用等特点,可以有效地提高音视频播放的质量和用户体验。

总之,这个音乐播放器实现了基本的音乐播放功能,并且通过一系列的监听器、定时任务和函数实现了一些附加的功能,可以作为初学者学习 Android 音乐播放器开发的练习项目。

 Comments
Comment plugin failed to load
Loading comment plugin
Powered by Hexo & Theme Keep
Unique Visitor Page View