175 lines
6.4 KiB
Kotlin
175 lines
6.4 KiB
Kotlin
package com.zhushenwudi.lovelivemusicplayer.util
|
||
|
||
import io.ktor.http.encodeURLPathPart
|
||
import com.zhushenwudi.lovelivemusicplayer.domain.Album
|
||
import com.zhushenwudi.lovelivemusicplayer.domain.Music
|
||
import com.zhushenwudi.lovelivemusicplayer.lyric.PlatformInfo
|
||
import com.zhushenwudi.lovelivemusicplayer.platform.AppLog
|
||
import com.zhushenwudi.lovelivemusicplayer.platform.FileSystem
|
||
import com.zhushenwudi.lovelivemusicplayer.settings.AppSettings
|
||
|
||
/**
|
||
* 将歌曲的目录和文件相对路径合并成专辑可独立使用的完整相对路径。
|
||
* 已经是完整路径时不重复拼接,兼容云端导入和旧数据库数据。
|
||
*/
|
||
internal fun joinMediaRelativePath(baseUrl: String?, path: String?): String? {
|
||
val child = path?.trim()?.trim('/', '\\').orEmpty()
|
||
if (child.isEmpty()) return null
|
||
val base = baseUrl?.trim()?.trim('/', '\\').orEmpty()
|
||
if (base.isEmpty()) return child
|
||
if (child == base || child.startsWith("$base/")) return child
|
||
return "$base/$child"
|
||
}
|
||
|
||
class PathResolver(
|
||
private val fs: FileSystem,
|
||
private val settings: AppSettings,
|
||
) {
|
||
/**
|
||
* 存储根路径。
|
||
* iOS Documents UUID 重装后会变,iOS 上不持久化 SP_SD_PATH;
|
||
* iOS 上始终使用 `FileSystem.musicRoot`。
|
||
*/
|
||
fun storageRoot(): String {
|
||
if (PlatformInfo.isIos) return fs.musicRoot
|
||
val sd = settings.sdPath.trim()
|
||
if (sd.isNotEmpty()) {
|
||
return sd.trimEnd('/', '\\') + "/"
|
||
}
|
||
return fs.musicRoot
|
||
}
|
||
|
||
fun absoluteMusicPath(music: Music): String? {
|
||
val base = music.baseUrl ?: return null
|
||
// iOS 本地路径存为 .wav;也兼容数据库中仍为 .flac 的行。
|
||
val path = AudioPathUtils.flac2wav(music.musicPath) ?: return null
|
||
return storageRoot() + base.trimStart('/') + path.trimStart('/')
|
||
}
|
||
|
||
/**
|
||
* 探测本地音频文件的绝对路径候选(iOS:优先 wav,回退 flac)。
|
||
*/
|
||
fun absoluteMusicPathCandidates(music: Music): List<String> {
|
||
val base = music.baseUrl ?: return emptyList()
|
||
val raw = music.musicPath?.trim().orEmpty()
|
||
if (raw.isEmpty()) return emptyList()
|
||
val root = storageRoot() + base.trimStart('/')
|
||
val primary = AudioPathUtils.flac2wav(raw) ?: raw
|
||
val candidates = linkedSetOf(root + primary.trimStart('/'))
|
||
if (PlatformInfo.isIos) {
|
||
val flac = AudioPathUtils.wav2flac(raw) ?: raw
|
||
if (flac != primary) {
|
||
candidates += root + flac.trimStart('/')
|
||
}
|
||
if (raw.endsWith(".flac", ignoreCase = true)) {
|
||
candidates += root + raw.trimStart('/')
|
||
}
|
||
}
|
||
return candidates.toList()
|
||
}
|
||
|
||
fun absoluteCoverPath(music: Music): String? {
|
||
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
|
||
return storageRoot() + relative
|
||
}
|
||
|
||
fun absoluteAlbumCoverPath(album: Album): String? {
|
||
val path = album.coverPath ?: return null
|
||
return storageRoot() + path.trimStart('/')
|
||
}
|
||
|
||
/**
|
||
* 拼接 HTTP 根路径与相对路径,逐段编码。
|
||
* 部分主机要求编码空格/Unicode(如 `μ's`、中文、`[]`)。
|
||
*/
|
||
fun joinHttpUrl(vararg relativeParts: String): String? {
|
||
val http = settings.httpUrl.trim()
|
||
if (http.isBlank()) return null
|
||
val rel = relativeParts.joinToString("").trimStart('/')
|
||
if (rel.isBlank()) return null
|
||
val encoded = rel.split('/')
|
||
.filter { it.isNotEmpty() }
|
||
.joinToString("/") { it.encodeURLPathPart() }
|
||
return http.trimEnd('/') + "/" + encoded
|
||
}
|
||
|
||
/**
|
||
* 歌曲封面 URI:本地 file://、HTTP URL,或 null(UI 显示默认 Logo)。
|
||
*/
|
||
fun coverDisplayUri(music: Music): String? {
|
||
if (music.musicId == null) return null
|
||
if (music.existFile == true) {
|
||
val path = absoluteCoverPath(music)
|
||
if (path != null && fs.exists(path)) {
|
||
return "file://$path"
|
||
}
|
||
}
|
||
if (settings.enableHttp) {
|
||
val relative = joinMediaRelativePath(music.baseUrl, music.coverPath) ?: return null
|
||
return joinHttpUrl(relative)
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 专辑封面 URI。 */
|
||
fun coverDisplayUri(album: Album): String? {
|
||
val rel = album.coverPath ?: return null
|
||
if (album.existFile == true) {
|
||
val path = absoluteAlbumCoverPath(album)
|
||
if (path != null && fs.exists(path)) {
|
||
return "file://$path"
|
||
}
|
||
}
|
||
if (settings.enableHttp) {
|
||
return joinHttpUrl(rel)
|
||
}
|
||
return null
|
||
}
|
||
|
||
fun playbackUri(music: Music): String? {
|
||
val id = music.musicId
|
||
val enableHttp = settings.enableHttp
|
||
val httpBlank = settings.httpUrl.isBlank()
|
||
if (enableHttp && !httpBlank) {
|
||
val base = music.baseUrl.orEmpty()
|
||
// HTTP 曲库为 flac;iOS 本地导入后数据库可能存 wav。
|
||
val path = AudioPathUtils.wav2flac(music.musicPath).orEmpty()
|
||
val joined = joinHttpUrl(base, path)
|
||
if (joined == null) {
|
||
AppLog.w(PLAY_TAG, "playbackUri http joinNull id=$id")
|
||
}
|
||
return joined
|
||
}
|
||
val candidates = absoluteMusicPathCandidates(music)
|
||
val local = candidates.firstOrNull { fs.exists(it) }
|
||
if (local != null) {
|
||
return "file://$local"
|
||
}
|
||
val fallback = music.musicPath?.takeIf {
|
||
it.startsWith("http") || it.startsWith("asset")
|
||
}
|
||
if (fallback == null) {
|
||
AppLog.w(
|
||
PLAY_TAG,
|
||
"playbackUri null id=$id enableHttp=$enableHttp httpBlank=$httpBlank " +
|
||
"candidates=$candidates root=${storageRoot()}",
|
||
)
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
fun refreshExistFile(music: Music): Music {
|
||
val exists = absoluteMusicPathCandidates(music).any { fs.exists(it) }
|
||
// 磁盘存在时,将 iOS 本地扩展名持久化到数据库(flac→wav)。
|
||
val pathForDb = when {
|
||
!PlatformInfo.isIos -> music.musicPath
|
||
else -> AudioPathUtils.flac2wav(music.musicPath)
|
||
}
|
||
return music.copy(musicPath = pathForDb, existFile = exists)
|
||
}
|
||
|
||
private companion object {
|
||
const val PLAY_TAG = "LLMP-Play"
|
||
}
|
||
}
|