Skip to content

Commit 067f94d

Browse files
committed
feat(android): support getAlbumsAsync
1 parent 441d470 commit 067f94d

File tree

9 files changed

+443
-23
lines changed

9 files changed

+443
-23
lines changed
Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
package com.musiclibrary.albums
22

3-
import android.Manifest
43
import android.content.Context
5-
import android.content.pm.PackageManager
6-
import android.os.Build
7-
import androidx.core.content.ContextCompat
84
import com.facebook.react.bridge.Promise
9-
import com.facebook.react.bridge.ReactApplicationContext
105
import com.musiclibrary.models.AssetsOptions
6+
import com.musiclibrary.utils.DataConverter
117

128
internal class GetAlbums(
139
private val context: Context,
1410
private val options: AssetsOptions,
1511
private val promise: Promise
1612
) {
17-
13+
1814
fun execute() {
1915
try {
20-
// TODO: implement
21-
promise.resolve(null)
16+
val contentResolver = context.contentResolver
17+
val result = GetAlbumsQuery.getAlbums(contentResolver, options)
18+
19+
val writableMap = DataConverter.paginatedResultToWritableMap(result) { album ->
20+
DataConverter.albumToWritableMap(album)
21+
}
22+
23+
promise.resolve(writableMap)
2224
} catch (e: Exception) {
2325
promise.reject("QUERY_ERROR", "Failed to query albums: ${e.message}", e)
2426
}
2527
}
26-
}
28+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.musiclibrary.albums
2+
3+
import android.content.ContentResolver
4+
import android.provider.MediaStore
5+
import android.net.Uri
6+
import android.provider.DocumentsContract
7+
import com.musiclibrary.models.*
8+
import androidx.core.net.toUri
9+
10+
object GetAlbumsQuery {
11+
fun getAlbums(
12+
contentResolver: ContentResolver,
13+
options: AssetsOptions,
14+
): PaginatedResult<Album> {
15+
val projection = arrayOf(
16+
MediaStore.Audio.Albums._ID,
17+
MediaStore.Audio.Albums.ALBUM,
18+
MediaStore.Audio.Albums.ARTIST,
19+
MediaStore.Audio.Albums.NUMBER_OF_SONGS,
20+
MediaStore.Audio.Albums.FIRST_YEAR,
21+
)
22+
23+
val selection = buildSelection(options)
24+
val selectionArgs = buildSelectionArgs(options)
25+
val sortOrder = buildSortOrder(options.sortBy)
26+
27+
val cursor = contentResolver.query(
28+
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
29+
projection,
30+
selection,
31+
selectionArgs,
32+
sortOrder
33+
) ?: throw RuntimeException("Failed to query MediaStore: cursor is null")
34+
35+
val albums = mutableListOf<Album>()
36+
var hasNextPage: Boolean
37+
var endCursor: String? = null
38+
val totalCount = cursor.count
39+
40+
cursor.use { c ->
41+
val idColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums._ID)
42+
val albumColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.ALBUM)
43+
val artistColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.ARTIST)
44+
val trackCountColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.NUMBER_OF_SONGS)
45+
val firstYearColumn = c.getColumnIndexOrThrow(MediaStore.Audio.Albums.FIRST_YEAR)
46+
47+
// Jump to the specified start position
48+
val foundAfter = if (options.after == null) {
49+
cursor.moveToFirst() // Move to the first record
50+
true
51+
} else {
52+
var found = false
53+
if (cursor.moveToFirst()) {
54+
do {
55+
val id = cursor.getLong(idColumn).toString()
56+
if (id == options.after) {
57+
found = true
58+
break
59+
}
60+
} while (cursor.moveToNext())
61+
}
62+
// Move to the next record after the specified after if found
63+
found && cursor.moveToNext()
64+
}
65+
66+
var count = 0
67+
val maxItems = options.first.coerceAtMost(1000) // Limit the maximum number of queries
68+
69+
while (foundAfter && count < maxItems) {
70+
try {
71+
val id = c.getLong(idColumn)
72+
val albumTitle = c.getString(albumColumn) ?: ""
73+
val artist = c.getString(artistColumn) ?: ""
74+
val trackCount = c.getInt(trackCountColumn)
75+
val firstYear = c.getInt(firstYearColumn)
76+
77+
// Skip invalid albums
78+
if (albumTitle.isEmpty() || trackCount == 0) {
79+
continue
80+
}
81+
82+
// Get artwork URI
83+
val artworkUri: Uri = "content://media/external/audio/albumart/${id}".toUri()
84+
85+
// Create an Album
86+
val album = Album(
87+
id = id.toString(),
88+
title = albumTitle,
89+
artist = artist,
90+
artwork = artworkUri.toString(),
91+
trackCount = trackCount,
92+
year = if (firstYear > 0) firstYear else null
93+
)
94+
95+
albums.add(album)
96+
endCursor = id.toString()
97+
count++
98+
} catch (e: Exception) {
99+
continue
100+
}
101+
102+
if (!cursor.moveToNext()) break
103+
}
104+
105+
// Check if there are more data
106+
hasNextPage = !c.isAfterLast
107+
}
108+
109+
return PaginatedResult(
110+
items = albums,
111+
hasNextPage = hasNextPage,
112+
endCursor = endCursor,
113+
totalCount = totalCount
114+
)
115+
}
116+
117+
private fun buildSelection(options: AssetsOptions): String {
118+
val conditions = mutableListOf<String>()
119+
120+
// Only query albums that have tracks
121+
conditions.add("${MediaStore.Audio.Albums.NUMBER_OF_SONGS} > 0")
122+
123+
// Directory filtering for albums is more complex since albums don't have direct path
124+
// We'll need to filter based on tracks in the album
125+
if (!options.directory.isNullOrEmpty()) {
126+
// Filter albums that have tracks in the specified directory
127+
conditions.add("EXISTS (SELECT 1 FROM ${MediaStore.Audio.Media.EXTERNAL_CONTENT_URI} WHERE ${MediaStore.Audio.Media.ALBUM_ID} = ${MediaStore.Audio.Albums._ID} AND ${MediaStore.Audio.Media.DATA} LIKE ?)")
128+
}
129+
130+
return conditions.joinToString(" AND ")
131+
}
132+
133+
private fun buildSelectionArgs(options: AssetsOptions): Array<String>? {
134+
val args = mutableListOf<String>()
135+
136+
if (!options.directory.isNullOrEmpty()) {
137+
val dir = if (options.directory.startsWith("content://")) {
138+
uriToFullPath(options.directory.toUri())
139+
} else {
140+
options.directory
141+
}
142+
143+
if (!dir.isNullOrEmpty()) {
144+
args.add("$dir%")
145+
}
146+
}
147+
148+
return if (args.isEmpty()) null else args.toTypedArray()
149+
}
150+
151+
private fun uriToFullPath(treeUri: Uri): String? {
152+
val docId = DocumentsContract.getTreeDocumentId(treeUri) // "primary:Music/abc"
153+
val parts = docId.split(":")
154+
if (parts.size < 2) return null
155+
156+
val type = parts[0]
157+
val relativePath = parts[1]
158+
159+
return when (type) {
160+
"primary" -> "/storage/emulated/0/$relativePath"
161+
else -> "/storage/$type/$relativePath"
162+
}
163+
}
164+
165+
private fun buildSortOrder(sortBy: List<String>): String {
166+
if (sortBy.isEmpty()) {
167+
return "${MediaStore.Audio.Albums.ALBUM} ASC"
168+
}
169+
170+
return sortBy.joinToString(", ") { sortOption ->
171+
val parts = sortOption.split(" ")
172+
require(parts.size == 2) { "sortBy should be 'key order'" }
173+
174+
val column = when (parts[0].lowercase()) {
175+
"default" -> MediaStore.Audio.Albums.ALBUM
176+
"artist" -> MediaStore.Audio.Albums.ARTIST
177+
"album" -> MediaStore.Audio.Albums.ALBUM
178+
"track_count" -> MediaStore.Audio.Albums.NUMBER_OF_SONGS
179+
"year" -> MediaStore.Audio.Albums.FIRST_YEAR
180+
else -> throw IllegalArgumentException("Unsupported SortKey: ${parts[0]}")
181+
}
182+
183+
val order = parts[1].uppercase()
184+
require(order == "ASC" || order == "DESC") { "Sort By must be ASC or DESC" }
185+
186+
"$column $order"
187+
}
188+
}
189+
}

android/src/main/java/com/musiclibrary/models/Assets.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ data class Album(
4444
val artist: String,
4545
val artwork: String? = null,
4646
val trackCount: Int,
47-
val duration: Double,
4847
val year: Int? = null,
4948
)
5049

android/src/main/java/com/musiclibrary/tracks/GetTracksQuery.kt

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ object GetTracksQuery {
3737
) ?: throw RuntimeException("Failed to query MediaStore: cursor is null")
3838

3939
val tracks = mutableListOf<Track>()
40-
var hasNextPage = false
40+
var hasNextPage: Boolean
4141
var endCursor: String? = null
4242
val totalCount = cursor.count
4343

@@ -199,10 +199,6 @@ object GetTracksQuery {
199199
val order = parts[1].uppercase()
200200
require(order == "ASC" || order == "DESC") { "Sort By must be ASC or DESC" }
201201

202-
if (column == null) {
203-
return@joinToString ""
204-
}
205-
206202
"$column $order"
207203
}
208204
}

android/src/main/java/com/musiclibrary/utils/DataConverter.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ object DataConverter {
3030
map.putString("artist", album.artist)
3131
album.artwork?.let { map.putString("artwork", it) }
3232
map.putInt("trackCount", album.trackCount)
33-
map.putDouble("duration", album.duration)
3433
album.year?.let { map.putInt("year", it) }
3534

3635
return map

example/src/navigation/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
33
import HomeScreen from '../pages/HomeScreen';
44
import TrackListScreen from '../pages/TrackListScreen';
55
import PlayerScreen from '../pages/PlayerScreen';
6+
import AlbumListScreen from '../pages/AlbumListScreen';
67

78
export type RootStackParamList = {
89
Home: undefined;
910
TrackList: undefined;
11+
AlbumList: undefined;
1012
Player: undefined;
1113
};
1214

@@ -29,6 +31,14 @@ export default function Navigation() {
2931
headerBackTitle: 'Back',
3032
}}
3133
/>
34+
<Stack.Screen
35+
name="AlbumList"
36+
component={AlbumListScreen}
37+
options={{
38+
title: 'Album List',
39+
headerBackTitle: 'Back',
40+
}}
41+
/>
3242
<Stack.Screen
3343
name="Player"
3444
component={PlayerScreen}

0 commit comments

Comments
 (0)