Skip to content

Commit a9dab9b

Browse files
committed
refactor: 重构本地音乐系统,SAF改为File API + 存储权限
- 重写LocalMusicScanner,删除SAF相关代码,改用java.io.File扫描Music/Mei/目录 - 新增scanAllMusic()自动扫描子文件夹作为歌单 - LocalMusicScreen删除SAF picker,改为存储权限授权后自动扫描 - LocalSongListScreen复用PlaylistBackground + PlaylistTrackList,支持平板布局 - 封面拼贴使用已有的FinalPerfectCollage组件 - 艺术家按分隔符(、/;|&)拆分显示 - 修复播放ID不匹配:resolver同时查询local_前缀和无前缀ID - 修复时长显示:toMediaMetadata()中秒转毫秒 - 修复封面图片:smallImage()对本地路径跳过CDN参数 - 修复文件夹重复:registerScanFolder先查已存在再插入 - 删除LocalStats统计栏 - AppDatabase升级到version 13,清理旧SAF数据 - MediaUriProvider/MusicService移除content://逻辑 - SongDao新增updateMetadata/getLocalSongsByArtistContains
1 parent 5ec2563 commit a9dab9b

16 files changed

Lines changed: 538 additions & 452 deletions

app/src/main/java/com/ljyh/mei/data/model/MediaMetadata.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ fun com.ljyh.mei.data.model.room.Song.toMediaMetadata(): MediaMetadata {
231231
title = title,
232232
coverUrl = cover,
233233
artists = listOf(MediaMetadata.Artist(id = artist.hashCode().toLong().let { if (it < 0) -it else it }, name = artist)),
234-
duration = duration,
234+
duration = duration * 1000,
235235
album = MediaMetadata.Album(id = album.hashCode().toLong().let { if (it < 0) -it else it }, title = album)
236236
)
237237
}

app/src/main/java/com/ljyh/mei/di/AppDatabase.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import com.ljyh.mei.di.dao.SongDao
3737
PlaybackHistory::class, AlbumEntity::class, ArtistEntity::class, AlbumArtistCrossRef::class,
3838
CachedLyric::class, DownloadTask::class, PlaylistSongCrossRef::class, ScanFolder::class
3939
],
40-
version = 12
40+
version = 13
4141
)
4242
abstract class AppDatabase : RoomDatabase() {
4343
abstract fun colorDao(): ColorDao
@@ -117,6 +117,16 @@ abstract class AppDatabase : RoomDatabase() {
117117
}
118118
}
119119

120+
val MIGRATION_12_13 = object : Migration(12, 13) {
121+
override fun migrate(db: SupportSQLiteDatabase) {
122+
// Remove all SAF-based content:// data, keep only real file paths
123+
db.execSQL("DELETE FROM song WHERE path LIKE 'content://%'")
124+
db.execSQL("DELETE FROM scan_folder WHERE path LIKE 'content://%'")
125+
db.execSQL("DELETE FROM playlist_song_cross_ref WHERE playlistId LIKE 'folder_%'")
126+
db.execSQL("DELETE FROM playlist WHERE type = 'FOLDER'")
127+
}
128+
}
129+
120130
@Volatile
121131
private var INSTANCE: AppDatabase? = null
122132

@@ -126,7 +136,7 @@ abstract class AppDatabase : RoomDatabase() {
126136
context.applicationContext,
127137
AppDatabase::class.java,
128138
"app_database"
129-
).addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
139+
).addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13)
130140
.build()
131141
.also { INSTANCE = it }
132142
}

app/src/main/java/com/ljyh/mei/di/dao/ScanFolderDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ interface ScanFolderDao {
1515
@Query("SELECT * FROM scan_folder")
1616
fun getAll(): Flow<List<ScanFolder>>
1717

18+
@Query("SELECT * FROM scan_folder WHERE path = :path LIMIT 1")
19+
suspend fun getByPath(path: String): ScanFolder?
20+
1821
@Insert(onConflict = OnConflictStrategy.REPLACE)
1922
suspend fun insert(folder: ScanFolder)
2023

app/src/main/java/com/ljyh/mei/di/dao/SongDao.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ interface SongDao {
3535
@Query("SELECT * FROM song WHERE artist = :artist AND path IS NOT NULL AND path != ''")
3636
fun getLocalSongsByArtist(artist: String): Flow<List<Song>>
3737

38+
@Query("SELECT * FROM song WHERE artist LIKE '%' || :artist || '%' AND path IS NOT NULL AND path != ''")
39+
fun getLocalSongsByArtistContains(artist: String): Flow<List<Song>>
40+
3841
@Query("SELECT * FROM song WHERE album = :album AND path IS NOT NULL AND path != ''")
3942
fun getLocalSongsByAlbum(album: String): Flow<List<Song>>
4043

@@ -44,6 +47,38 @@ interface SongDao {
4447
@Query("UPDATE song SET path = :path, updatedAt = :time WHERE id = :id")
4548
suspend fun updatePath(id: String, path: String?, time: Long = System.currentTimeMillis())
4649

50+
@Query("""
51+
UPDATE song SET
52+
title = :title,
53+
artist = :artist,
54+
album = :album,
55+
cover = :cover,
56+
duration = :duration,
57+
path = :path,
58+
fileHash = :fileHash,
59+
fileSize = :fileSize,
60+
fileFormat = :fileFormat,
61+
bitrate = :bitrate,
62+
sampleRate = :sampleRate,
63+
updatedAt = :time
64+
WHERE id = :id
65+
""")
66+
suspend fun updateMetadata(
67+
id: String,
68+
title: String,
69+
artist: String,
70+
album: String,
71+
cover: String,
72+
duration: Long,
73+
path: String?,
74+
fileHash: String?,
75+
fileSize: Long,
76+
fileFormat: String?,
77+
bitrate: Int?,
78+
sampleRate: Int?,
79+
time: Long = System.currentTimeMillis()
80+
)
81+
4782
@Insert(onConflict = OnConflictStrategy.REPLACE)
4883
suspend fun insertSong(song: Song)
4984

app/src/main/java/com/ljyh/mei/di/repository/LocalPlaylistRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class PlaylistSongCrossRefRepository @Inject constructor(private val dao: Playli
2727
class ScanFolderRepository @Inject constructor(private val dao: com.ljyh.mei.di.dao.ScanFolderDao) {
2828
fun getEnabled(): Flow<List<com.ljyh.mei.data.model.room.ScanFolder>> = dao.getEnabled()
2929
fun getAll(): Flow<List<com.ljyh.mei.data.model.room.ScanFolder>> = dao.getAll()
30+
suspend fun getByPath(path: String): com.ljyh.mei.data.model.room.ScanFolder? = dao.getByPath(path)
3031
suspend fun insert(folder: com.ljyh.mei.data.model.room.ScanFolder) = dao.insert(folder)
3132
suspend fun updateScanResult(id: Long, time: Long, count: Int) = dao.updateScanResult(id, time, count)
3233
suspend fun setEnabled(id: Long, enabled: Boolean) = dao.setEnabled(id, enabled)

app/src/main/java/com/ljyh/mei/di/repository/SongRepository.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@ class SongRepository @Inject constructor(private val songDao: SongDao) {
2020
fun getLocalSongsByAlbum(album: String): Flow<List<Song>> = songDao.getLocalSongsByAlbum(album)
2121
fun getLosslessSongs(): Flow<List<Song>> = songDao.getLosslessSongs()
2222
suspend fun updatePath(id: String, path: String?) = songDao.updatePath(id, path)
23+
24+
suspend fun updateMetadata(
25+
id: String,
26+
title: String,
27+
artist: String,
28+
album: String,
29+
cover: String,
30+
duration: Long,
31+
path: String?,
32+
fileHash: String?,
33+
fileSize: Long,
34+
fileFormat: String?,
35+
bitrate: Int?,
36+
sampleRate: Int?
37+
) = songDao.updateMetadata(
38+
id, title, artist, album, cover, duration, path, fileHash, fileSize, fileFormat, bitrate, sampleRate
39+
)
2340
suspend fun insertSong(song: Song) = songDao.insertSong(song)
2441
suspend fun insertSongs(songs: List<Song>) = songDao.insertSongs(songs)
2542
suspend fun deleteById(id: String) = songDao.deleteById(id)

app/src/main/java/com/ljyh/mei/playback/MediaUriProvider.kt

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,35 @@ package com.ljyh.mei.playback
33
import android.net.Uri
44
import androidx.core.net.toUri
55
import com.ljyh.mei.data.model.api.GetSongUrlV1
6-
import com.ljyh.mei.data.network.api.ApiService // 假设你的API都在这
7-
import com.ljyh.mei.di.repository.SongRepository // 假设你处理本地文件
8-
import kotlinx.coroutines.Dispatchers
6+
import com.ljyh.mei.data.network.api.ApiService
7+
import com.ljyh.mei.di.repository.SongRepository
98
import kotlinx.coroutines.flow.firstOrNull
10-
import kotlinx.coroutines.withContext
119
import timber.log.Timber
12-
import javax.inject.Inject
13-
import javax.inject.Singleton
1410
import java.io.File
1511
import java.io.IOException
12+
import java.util.concurrent.ConcurrentHashMap
13+
import javax.inject.Inject
14+
import javax.inject.Singleton
1615

17-
// 自定义异常,用于精准捕获
1816
class SourceNotFoundException(message: String) : IOException(message)
1917

2018
@Singleton
2119
class MediaUriProvider @Inject constructor(
2220
private val apiService: ApiService,
2321
private val songRepository: SongRepository,
2422
) {
25-
private val urlCache = java.util.concurrent.ConcurrentHashMap<String, String>()
23+
private val urlCache = ConcurrentHashMap<String, String>()
2624

2725
suspend fun resolveMediaUri(mediaId: String, quality: String): Uri {
28-
// 1. 检查本地文件 (你的原有逻辑)
2926
val localPath = songRepository.getSong(mediaId).firstOrNull()?.path
30-
if (localPath != null && File(localPath).exists()) {
31-
return Uri.fromFile(File(localPath))
27+
?: songRepository.getSong("local_$mediaId").firstOrNull()?.path
28+
if (localPath != null) {
29+
val file = File(localPath)
30+
if (file.exists()) {
31+
return Uri.fromFile(file)
32+
}
3233
}
34+
3335
urlCache[mediaId]?.let { return it.toUri() }
3436
return try {
3537
val response = apiService.getSongUrlV1(
@@ -38,7 +40,7 @@ class MediaUriProvider @Inject constructor(
3840
val url = response.data.getOrNull(0)?.url
3941

4042
if (url.isNullOrBlank()) {
41-
Timber.tag("MediaUriProvider").d( response.toString())
43+
Timber.tag("MediaUriProvider").d(response.toString())
4244
throw SourceNotFoundException("API returned empty URL for $mediaId")
4345
}
4446

@@ -49,4 +51,4 @@ class MediaUriProvider @Inject constructor(
4951
throw IOException("Network error resolving URL for $mediaId", e)
5052
}
5153
}
52-
}
54+
}

app/src/main/java/com/ljyh/mei/playback/MusicService.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,9 @@ class MusicService : MediaLibraryService(),
422422
return ResolvingDataSource.Factory(getCacheDataSourceFactory(context)) { dataSpec ->
423423
val mediaId = dataSpec.key ?: error("No media key")
424424
val localFilePath = runBlocking {
425-
songRepository.getSong(mediaId).firstOrNull()?.path
425+
val song = songRepository.getSong(mediaId).firstOrNull()
426+
?: songRepository.getSong("local_$mediaId").firstOrNull()
427+
song?.path
426428
}
427429
if (localFilePath != null) {
428430
val file = File(localFilePath)
@@ -431,10 +433,8 @@ class MusicService : MediaLibraryService(),
431433
return@Factory dataSpec.withUri(Uri.fromFile(file))
432434
}
433435
}
434-
// 检查磁盘缓存 (ExoPlayer Cache) 是否已完全缓存
435436
if (isContentFullyCached(simpleCache, mediaId)) {
436437
Timber.tag("ResolvingDataSource").d("Fully cached on disk: $mediaId")
437-
// 直接返回原始 DataSpec 即可,CacheDataSource 会自动从磁盘读
438438
return@Factory dataSpec
439439
}
440440

app/src/main/java/com/ljyh/mei/ui/screen/local/LocalMusicScreen.kt

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.ljyh.mei.ui.screen.local
22

3-
import androidx.activity.compose.rememberLauncherForActivityResult
4-
import androidx.activity.result.contract.ActivityResultContracts
3+
import android.app.Activity
54
import androidx.compose.foundation.layout.Box
65
import androidx.compose.foundation.layout.Column
76
import androidx.compose.foundation.layout.PaddingValues
@@ -16,8 +15,10 @@ import androidx.compose.foundation.lazy.items
1615
import androidx.compose.material.icons.Icons
1716
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
1817
import androidx.compose.material.icons.rounded.MusicNote
18+
import androidx.compose.material.icons.rounded.Refresh
1919
import androidx.compose.material3.ExperimentalMaterial3Api
2020
import androidx.compose.material3.Icon
21+
import androidx.compose.material3.IconButton
2122
import androidx.compose.material3.MaterialTheme
2223
import androidx.compose.material3.Scaffold
2324
import androidx.compose.material3.Text
@@ -27,6 +28,7 @@ import androidx.compose.material3.TextButton
2728
import androidx.compose.material3.TopAppBar
2829
import androidx.compose.material3.TopAppBarScrollBehavior
2930
import androidx.compose.runtime.Composable
31+
import androidx.compose.runtime.LaunchedEffect
3032
import androidx.compose.runtime.collectAsState
3133
import androidx.compose.runtime.getValue
3234
import androidx.compose.runtime.mutableStateOf
@@ -41,9 +43,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
4143
import com.ljyh.mei.di.AppDatabase
4244
import com.ljyh.mei.data.model.room.Playlist
4345
import com.ljyh.mei.data.model.room.PlaylistType
44-
import com.ljyh.mei.data.model.room.ScanFolder
45-
import com.ljyh.mei.data.model.room.Song
46-
import com.ljyh.mei.data.model.room.SourceType
4746
import com.ljyh.mei.ui.local.LocalNavController
4847
import com.ljyh.mei.ui.local.LocalPlayerAwareWindowInsets
4948
import com.ljyh.mei.ui.screen.Screen
@@ -52,13 +51,23 @@ import com.ljyh.mei.ui.screen.local.component.AlbumRow
5251
import com.ljyh.mei.ui.screen.local.component.ArtistRow
5352
import com.ljyh.mei.ui.screen.local.component.EmptyLocalMusic
5453
import com.ljyh.mei.ui.screen.local.component.FolderItem
55-
import com.ljyh.mei.ui.screen.local.component.LibraryStats
5654
import com.ljyh.mei.ui.screen.local.component.ManagementCard
5755
import com.ljyh.mei.ui.screen.local.component.ManagementCards
5856
import com.ljyh.mei.ui.screen.local.component.ScanProgressCard
5957
import com.ljyh.mei.ui.screen.local.component.SectionHeader
58+
import com.ljyh.mei.utils.PermissionsUtils
6059
import kotlinx.coroutines.launch
6160

61+
private val ARTIST_SEPARATORS = Regex("[、/;|&]")
62+
63+
private fun splitArtists(artist: String): List<String> {
64+
if (artist.isBlank()) return emptyList()
65+
return artist.split(ARTIST_SEPARATORS)
66+
.map { it.trim() }
67+
.filter { it.isNotEmpty() }
68+
.ifEmpty { listOf(artist.trim()) }
69+
}
70+
6271
@OptIn(ExperimentalMaterial3Api::class)
6372
@Composable
6473
fun LocalMusicScreen(
@@ -70,24 +79,24 @@ fun LocalMusicScreen(
7079
val db = AppDatabase.getDatabase(context)
7180

7281
val localSongs by db.songDao().getLocalSongs().collectAsState(initial = emptyList())
73-
val artists by db.songDao().getLocalArtists().collectAsState(initial = emptyList())
7482
val albums by db.songDao().getLocalAlbums().collectAsState(initial = emptyList())
7583
val scanFolders by db.scanFolderDao().getAll().collectAsState(initial = emptyList())
7684
val scanState by viewModel.scanState.collectAsState()
85+
val hasPermission by viewModel.hasPermission.collectAsState()
86+
87+
val artists = remember(localSongs) {
88+
localSongs.flatMap { song ->
89+
splitArtists(song.artist)
90+
}.distinct().sorted()
91+
}
7792

7893
var showCreatePlaylistDialog by remember { mutableStateOf(false) }
7994
var newPlaylistName by remember { mutableStateOf("") }
8095
val scope = rememberCoroutineScope()
8196

82-
val folderPickerLauncher = rememberLauncherForActivityResult(
83-
contract = ActivityResultContracts.OpenDocumentTree()
84-
) { uri ->
85-
if (uri != null) {
86-
context.contentResolver.takePersistableUriPermission(
87-
uri,
88-
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
89-
)
90-
viewModel.scanFolderUri(uri)
97+
LaunchedEffect(hasPermission) {
98+
if (hasPermission) {
99+
viewModel.scanAllMusic()
91100
}
92101
}
93102

@@ -107,6 +116,18 @@ fun LocalMusicScreen(
107116
)
108117
}
109118
},
119+
actions = {
120+
IconButton(
121+
onClick = { viewModel.scanAllMusic() },
122+
enabled = !scanState.isScanning && hasPermission
123+
) {
124+
Icon(
125+
imageVector = Icons.Rounded.Refresh,
126+
tint = MaterialTheme.colorScheme.onSurface,
127+
contentDescription = "重新扫描"
128+
)
129+
}
130+
},
110131
scrollBehavior = scrollBehavior
111132
)
112133
}
@@ -125,20 +146,33 @@ fun LocalMusicScreen(
125146
ScanProgressCard(scanState)
126147
}
127148

128-
if (localSongs.isEmpty() && !scanState.isScanning) {
149+
if (!hasPermission) {
150+
Box(
151+
modifier = Modifier.fillMaxSize(),
152+
contentAlignment = Alignment.Center
153+
) {
154+
EmptyLocalMusic(
155+
onAddFolder = {
156+
val activity = context as? Activity
157+
if (activity != null) {
158+
PermissionsUtils.checkAndRequestFilesPermissions(activity)
159+
viewModel.checkPermission()
160+
}
161+
}
162+
)
163+
}
164+
} else if (localSongs.isEmpty() && !scanState.isScanning) {
129165
Box(
130166
modifier = Modifier.fillMaxSize(),
131167
contentAlignment = Alignment.Center
132168
) {
133-
EmptyLocalMusic(onAddFolder = { folderPickerLauncher.launch(null) })
169+
EmptyLocalMusic(onAddFolder = { viewModel.scanAllMusic() })
134170
}
135171
} else {
136172
LazyColumn(
137173
modifier = Modifier.fillMaxSize(),
138174
contentPadding = PaddingValues(bottom = 16.dp)
139175
) {
140-
item { LibraryStats(localSongs) }
141-
142176
item { SectionHeader("歌曲", "${localSongs.size}") }
143177
item {
144178
ManagementCard(
@@ -203,7 +237,7 @@ fun LocalMusicScreen(
203237
item { SectionHeader("管理", null) }
204238
item {
205239
ManagementCards(
206-
onAddFolder = { folderPickerLauncher.launch(null) },
240+
onAddFolder = { viewModel.scanAllMusic() },
207241
onCreatePlaylist = { showCreatePlaylistDialog = true }
208242
)
209243
}

0 commit comments

Comments
 (0)