Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

- **本地曲库扫描**:通过 `MediaStore` 读取设备音乐,按 歌曲 / 专辑 / 艺术家 分组浏览
- **曲库排序**:歌曲与收藏列表可按 标题 / 艺术家 / 专辑(升序)或 时长(降序)排序,选择记忆于 `SharedPreferences`(借鉴 Music Player GO / Auxio)
- **随机播放全部**:曲库顶栏「随机」一键打乱全部歌曲并开始播放(借鉴 Auxio 的播放/随机入口),不改动当前播放模式记忆
- **续播更可靠**:播放中每约 15 秒持久化一次进度,即便被系统强杀也能恢复到近似位置;应用重启后从迷你播放器点播放可正确续播到上次位置(修复此前对未准备播放器调用 `start()` 的崩溃)
- **播放控制**:播放 / 暂停 / 上一首 / 下一首 / 拖动进度;顺序、随机、单曲循环、列表循环
- **后台播放**:媒体播放前台服务,锁屏与控制中心可控,媒体通知常驻
- **歌词**:优先匹配同目录同名 `.lrc`,其次读取文件内嵌歌词——FLAC / OGG(Vorbis Comment `LYRICS`/`UNSYNCEDLYRICS`)、MP3(ID3v2 `USLT`)、M4A / MP4(`©lyr` 原子)、WAV(RIFF LIST/INFO 的 `lyr`/`ILT` 块);当前行高亮 + 平滑滚动;无时间戳的纯文本歌词逐行兜底展示。解析为纯 Kotlin 字节解析,零额外依赖
Expand Down
138 changes: 94 additions & 44 deletions app/src/main/java/com/puremusicplayer/player/PlayerService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ class PlayerService : android.app.Service() {
private var progressTick = 0
/** 本次播放需要续播到的位置(毫秒);0 表示从头播放 */
private var resumePosition = 0
/** MediaPlayer 是否已准备就绪(prepareAsync 完成)。
* 未准备时不可直接 start()/seekTo(),否则抛 IllegalStateException。
* 这是“失败 CL”续播崩溃的根因:服务刚创建、歌曲已由 restoreQueue 恢复,
* 但 MediaPlayer 还没 prepare,此时点播放会直接 start() 而崩溃。 */
private var prepared = false

private var foregroundStarted = false

Expand Down Expand Up @@ -76,6 +81,8 @@ class PlayerService : android.app.Service() {
mediaPlayer = MediaPlayer().apply {
setWakeMode(applicationContext, PowerManager.PARTIAL_WAKE_LOCK)
setOnPreparedListener { mp ->
// 标记已准备就绪:此后才可安全地 start() / seekTo()
prepared = true
// 续播:回到上次保存的播放位置(由 restoreQueue 经 pendingResumePosition 传入)
val savedPos = resumePosition
resumePosition = 0
Expand All @@ -86,13 +93,15 @@ class PlayerService : android.app.Service() {
mp.start()
PlayerManager.isPlaying.value = true
PlayerManager.duration.value = mp.duration
// 若服务创建时音频会话尚未就绪导致均衡器未初始化,此时会话已有效,补初始化
// 若服务创建时音频会话尚未就绪导致均衡器/可视化未初始化,此时会话已有效,补初始化
if (Prefs.equalizerEnabled && !EqualizerHelper.isInitialized()) {
EqualizerHelper.init(mp.audioSessionId)
EqualizerHelper.setEnabled(true)
val presets = EqualizerHelper.Preset.values()
EqualizerHelper.applyPreset(presets[Prefs.equalizerPreset.coerceIn(0, presets.size - 1)])
}
// 与均衡器兜底对称:会话此刻一定有效,若可视化尚未建立则补建,保证首曲可视化可用
if (visualizer == null) ensureVisualizer(mp.audioSessionId)
applyPlaybackSpeed(mp)
updatePlaybackState()
updateNotification()
Expand All @@ -117,19 +126,17 @@ class PlayerService : android.app.Service() {
if (PlayerManager.currentIndex < 0 && PlayerManager.playlist.isNotEmpty()) {
PlayerManager.currentIndex = 0
}
// 仅当本次播放的歌曲与「恢复出的歌曲」一致时,才续播到上次位置
val song = PlayerManager.playlist.getOrNull(PlayerManager.currentIndex)
resumePosition = if (song != null && song.favKey() == PlayerManager.resumeSongKey) {
PlayerManager.pendingResumePosition
} else 0
PlayerManager.resumeSongKey = null
PlayerManager.pendingResumePosition = 0
// 显式播放当前选择:始终(重新)准备并播放,续播位置一并应用
prepareResumePosition()
playAt(PlayerManager.currentIndex, true)
}
PlayerControls.ACTION_PAUSE -> pause()
PlayerControls.ACTION_PLAY_PAUSE -> togglePlayPause()
PlayerControls.ACTION_NEXT -> playAt(step(true), true)
PlayerControls.ACTION_PREV -> playAt(step(false), true)
PlayerControls.ACTION_PLAY_PAUSE -> {
if (mediaPlayer.isPlaying) pause()
else { prepareResumePosition(); playOrResume() }
}
PlayerControls.ACTION_NEXT -> { resumePosition = 0; playAt(step(true), true) }
PlayerControls.ACTION_PREV -> { resumePosition = 0; playAt(step(false), true) }
PlayerControls.ACTION_SEEK ->
seekTo(intent.getIntExtra(PlayerControls.EXTRA_POSITION, 0))
PlayerControls.ACTION_SET_MODE -> {
Expand Down Expand Up @@ -181,6 +188,7 @@ class PlayerService : android.app.Service() {
if (index !in list.indices) return
PlayerManager.currentIndex = index
PlayerManager.saveQueue(this)
prepared = false // 即将重新准备,标记为未就绪
val song = list[index]
try {
mediaPlayer.reset()
Expand All @@ -198,32 +206,57 @@ class PlayerService : android.app.Service() {
}

private fun resume() {
if (PlayerManager.current() == null && PlayerManager.playlist.isNotEmpty()) {
playAt(0, true); return
}
if (!mediaPlayer.isPlaying) {
mediaPlayer.start()
PlayerManager.isPlaying.value = true
updatePlaybackState()
updateNotification()
}
prepareResumePosition()
playOrResume()
}

private fun pause() {
if (mediaPlayer.isPlaying) {
mediaPlayer.pause()
PlayerManager.isPlaying.value = false
PlayerManager.saveQueue(this)
if (!prepared || !mediaPlayer.isPlaying) return
mediaPlayer.pause()
PlayerManager.isPlaying.value = false
PlayerManager.saveQueue(this)
updatePlaybackState()
updateNotification()
}

/**
* 计算续播位置:仅当「恢复出的歌曲」与当前曲目一致时,
* 才把待续播位置写入服务级 resumePosition;随后清空 PlayerManager 的待续播信息,
* 避免误续播到其它歌曲。与 PR#1 修复的「续播位置被清零」对称。
*/
private fun prepareResumePosition() {
val song = PlayerManager.playlist.getOrNull(PlayerManager.currentIndex)
resumePosition = if (song != null && song.favKey() == PlayerManager.resumeSongKey) {
PlayerManager.pendingResumePosition
} else 0
PlayerManager.resumeSongKey = null
PlayerManager.pendingResumePosition = 0
}

/**
* 播放或恢复当前曲目:
* - 已准备就绪且处于暂停态 → 直接 start(瞬时恢复,含暂停位置)
* - 未准备就绪(如刚创建服务、续播场景,MediaPlayer 尚未 prepare)→ 走 playAt 准备
* 这避免了「失败 CL」中对未准备播放器调用 start() 的崩溃。
*/
private fun playOrResume() {
// 无有效曲目时回退到列表首曲
if (PlayerManager.currentIndex !in PlayerManager.playlist.indices) {
if (PlayerManager.playlist.isNotEmpty()) PlayerManager.currentIndex = 0
else return
}
if (prepared && ::mediaPlayer.isInitialized && !mediaPlayer.isPlaying) {
mediaPlayer.start()
PlayerManager.isPlaying.value = true
updatePlaybackState()
updateNotification()
} else if (!prepared) {
playAt(PlayerManager.currentIndex, true)
}
}

private fun togglePlayPause() {
if (mediaPlayer.isPlaying) pause() else resume()
}

private fun seekTo(posMs: Int) {
if (!prepared) return
if (posMs in 0..mediaPlayer.duration) {
mediaPlayer.seekTo(posMs)
PlayerManager.progress.value = posMs
Expand All @@ -248,6 +281,7 @@ class PlayerService : android.app.Service() {
PlayerManager.sleepRemaining.value = 0
pause()
} else {
resumePosition = 0 // 下一首从头播放,避免误带续播位置
playAt(step(true), true)
}
}
Expand Down Expand Up @@ -418,26 +452,36 @@ class PlayerService : android.app.Service() {
val idx = Prefs.equalizerPreset.coerceIn(0, presets.size - 1)
EqualizerHelper.applyPreset(presets[idx])
}
ensureVisualizer(sessionId)
} catch (_: Exception) {
visualizer = null
}
}

/** 为指定有效音频会话创建可视化(若尚未创建);会话无效或已创建则跳过 */
private fun ensureVisualizer(sessionId: Int) {
if (sessionId <= 0 || visualizer != null) return
try {
visualizer = Visualizer(sessionId).apply {
captureSize = Visualizer.getCaptureSizeRange()[1]
setDataCaptureListener(
object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture(
v: Visualizer?, waveform: ByteArray?, s: Int
) {
waveform?.let { PlayerManager.dispatchWave(it) }
}

override fun onFftDataCapture(
v: Visualizer?, fft: ByteArray?, s: Int
) {
fft?.let { PlayerManager.dispatchFft(it) }
}
},
Visualizer.getMaxCaptureRate(),
true,
true
)
override fun onWaveFormDataCapture(
v: Visualizer?, waveform: ByteArray?, s: Int
) {
waveform?.let { PlayerManager.dispatchWave(it) }
}

override fun onFftDataCapture(
v: Visualizer?, fft: ByteArray?, s: Int
) {
fft?.let { PlayerManager.dispatchFft(it) }
}
},
Visualizer.getMaxCaptureRate(),
true,
true
)
enabled = true
}
} catch (_: Exception) {
Expand Down Expand Up @@ -603,12 +647,18 @@ class PlayerService : android.app.Service() {

// ---------- 进度循环 ----------
private var progressRunnable: Runnable? = null
private var saveTick = 0 // 进度持久化计数:每约 15s 写一次,保证续播位置不过期
private fun startProgressLoop() {
progressRunnable = object : Runnable {
override fun run() {
if (::mediaPlayer.isInitialized && mediaPlayer.isPlaying) {
PlayerManager.progress.value = mediaPlayer.currentPosition
if (progressTick++ % 2 == 0) updatePlaybackState()
// 周期持久化播放进度(约 15s),即便被系统强杀也能恢复到近似位置
if (saveTick++ >= 30) {
saveTick = 0
PlayerManager.saveQueue(this@PlayerService)
}
}
handler.postDelayed(this, 500)
}
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/com/puremusicplayer/ui/LibraryFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ class LibraryFragment : Fragment() {
sortMode = Prefs.sortModeOrdinal
binding.sortButton.setOnClickListener { showSortDialog() }

// 借鉴 Auxio:顶栏“随机”按钮,一键打乱全部歌曲并开始播放
binding.shuffleButton.setOnClickListener { shuffleAll() }

// 收藏集合变化时刷新(迷你栏/播放页也可能修改收藏)
PlayerManager.favorites.observe(viewLifecycleOwner) { set ->
favoritesList = allSongs.filter { set.contains(it.favKey()) }
Expand Down Expand Up @@ -301,6 +304,19 @@ class LibraryFragment : Fragment() {
(activity as? MainActivity)?.switchToNowPlaying()
}

/** 借鉴 Auxio:打乱全部歌曲(保留当前目录/筛选范围)并开始播放 */
private fun shuffleAll() {
if (allSongs.isEmpty()) return
val shuffled = allSongs.shuffled()
PlayerManager.playlist.clear()
PlayerManager.playlist.addAll(shuffled)
PlayerManager.currentIndex = 0
PlayerManager.playMode = PlayMode.REPEAT_ALL
Prefs.playModeOrdinal = PlayMode.REPEAT_ALL.ordinal
PlayerControls.play(requireContext())
(activity as? MainActivity)?.switchToNowPlaying()
}

private fun playGroup(songs: List<Song>) {
// 单曲或少于 5 首直接播放
if (songs.size <= 5) {
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/res/layout/fragment_library.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@
android:focusable="true"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/sort_by" />

<TextView
android:id="@+id/shuffleButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/shuffle_button"
android:textSize="13sp"
android:textColor="?attr/colorPrimary"
android:gravity="center"
android:paddingStart="10dp"
android:paddingEnd="6dp"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:clickable="true"
android:focusable="true"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/action_shuffle" />
</LinearLayout>

<com.google.android.material.tabs.TabLayout
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<!-- 曲库排序 -->
<string name="sort">排序</string>
<string name="sort_by">排序方式</string>
<string name="shuffle_button">随机</string>
<string name="sort_title">标题</string>
<string name="sort_artist">艺术家</string>
<string name="sort_album">专辑</string>
Expand Down