feat: scan media store for pairing
This commit is contained in:
2
BUGS.md
2
BUGS.md
@@ -1,5 +1,7 @@
|
|||||||
# Bugs
|
# Bugs
|
||||||
|
|
||||||
|
- annotation playback: check what happens if some songs are not paired in the playlist. I believe the index is wrong and the player plays a different song from what is displayed as title and artist.
|
||||||
|
|
||||||
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
- syncJukeboxIfToken() is always called at startup, even if we have a jukebox already
|
||||||
|
|
||||||
- "Caveat: If a sync gets past the fetch phase, runs clearAllTables(), then dies while writing some playlists, you could still end up with a partial cache. Fixing that would mean a single Room transaction or a “swap” strategy; say if you want that next."
|
- "Caveat: If a sync gets past the fetch phase, runs clearAllTables(), then dies while writing some playlists, you could still end up with a partial cache. Fixing that would mean a single Room transaction or a “swap” strategy; say if you want that next."
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import at.lockstep.player.util.AudioUriValidator
|
|||||||
import at.lockstep.player.playback.TrackBoundaryEvent
|
import at.lockstep.player.playback.TrackBoundaryEvent
|
||||||
import at.lockstep.player.util.BeatAnnotationStorage
|
import at.lockstep.player.util.BeatAnnotationStorage
|
||||||
import at.lockstep.player.util.FolderMp3Scanner
|
import at.lockstep.player.util.FolderMp3Scanner
|
||||||
|
import at.lockstep.player.util.MediaStoreMp3Scanner
|
||||||
import at.lockstep.player.util.Mp3EmbeddedMetadata
|
import at.lockstep.player.util.Mp3EmbeddedMetadata
|
||||||
import at.lockstep.player.util.Mp3FolderCandidate
|
import at.lockstep.player.util.Mp3FolderCandidate
|
||||||
import at.lockstep.player.util.TrackFileMatching
|
import at.lockstep.player.util.TrackFileMatching
|
||||||
@@ -293,25 +294,7 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
||||||
val withId = tracks.count { !it.trackId.isNullOrBlank() }
|
logJukeboxTracks(tracks)
|
||||||
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
|
|
||||||
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
|
|
||||||
"withTitle=$withTitle withArtist=$withArtist",
|
|
||||||
)
|
|
||||||
tracks.take(12).forEach { row ->
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
|
|
||||||
"artist='${row.artistName}' durationMs=${row.durationMs}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (tracks.size > 12) {
|
|
||||||
Log.d(TAG, " … ${tracks.size - 12} more rows omitted from log")
|
|
||||||
}
|
|
||||||
|
|
||||||
val (mp3Total, pool) =
|
val (mp3Total, pool) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
|
val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri)
|
||||||
@@ -328,75 +311,117 @@ class LockstepViewModel(
|
|||||||
}.toMutableList()
|
}.toMutableList()
|
||||||
Pair(mp3s.size, p)
|
Pair(mp3s.size, p)
|
||||||
}
|
}
|
||||||
|
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
|
||||||
if (pool.isEmpty()) {
|
|
||||||
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
|
|
||||||
onFinished(
|
|
||||||
FolderPairingResult(
|
|
||||||
paired = 0,
|
|
||||||
failed = 0,
|
|
||||||
jukeboxRowCount = tracks.size,
|
|
||||||
mp3Count = mp3Total,
|
|
||||||
skippedNoSpotifyTrackId = 0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
|
|
||||||
"files have title or artist from tags",
|
|
||||||
)
|
|
||||||
|
|
||||||
var paired = 0
|
|
||||||
var failed = 0
|
|
||||||
var skippedNoSpotifyTrackId = 0
|
|
||||||
for (track in tracks.sortedBy { it.position }) {
|
|
||||||
val tid = track.trackId
|
|
||||||
if (tid.isNullOrBlank()) {
|
|
||||||
skippedNoSpotifyTrackId++
|
|
||||||
failed++
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"skip row pos=${track.position}: no Spotify track id " +
|
|
||||||
"(title='${track.trackName}' artist='${track.artistName}')",
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val title = track.trackName.orEmpty()
|
|
||||||
val artist = track.artistName.orEmpty()
|
|
||||||
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
|
|
||||||
if (pick == null) {
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val err =
|
|
||||||
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
|
|
||||||
if (err != null) {
|
|
||||||
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
|
|
||||||
upsertPairing(playlistId, tid, null, err)
|
|
||||||
failed++
|
|
||||||
} else {
|
|
||||||
upsertPairing(playlistId, tid, pick.toString(), null)
|
|
||||||
paired++
|
|
||||||
pool.removeAll { it.uri == pick }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"pairPlaylistFromFolder done paired=$paired failed=$failed " +
|
|
||||||
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
|
|
||||||
)
|
|
||||||
onFinished(
|
|
||||||
FolderPairingResult(
|
|
||||||
paired = paired,
|
|
||||||
failed = failed,
|
|
||||||
jukeboxRowCount = tracks.size,
|
|
||||||
mp3Count = mp3Total,
|
|
||||||
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pair against all MP3s indexed on the device (Music, Downloads, etc.) — no folder picker needed. */
|
||||||
|
fun pairPlaylistFromDeviceAudio(
|
||||||
|
playlistId: String,
|
||||||
|
onFinished: (FolderPairingResult) -> Unit,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Log.d(TAG, "pairPlaylistFromDeviceAudio playlistId=$playlistId")
|
||||||
|
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
||||||
|
logJukeboxTracks(tracks)
|
||||||
|
val (mp3Total, pool) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val candidates = MediaStoreMp3Scanner.listMp3Candidates(context)
|
||||||
|
Pair(candidates.size, candidates.toMutableList())
|
||||||
|
}
|
||||||
|
onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logJukeboxTracks(tracks: List<TrackRow>) {
|
||||||
|
val withId = tracks.count { !it.trackId.isNullOrBlank() }
|
||||||
|
val withTitle = tracks.count { !it.trackName.isNullOrBlank() }
|
||||||
|
val withArtist = tracks.count { !it.artistName.isNullOrBlank() }
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"jukebox DB rows for playlist: total=${tracks.size} withSpotifyTrackId=$withId " +
|
||||||
|
"withTitle=$withTitle withArtist=$withArtist",
|
||||||
|
)
|
||||||
|
tracks.take(12).forEach { row ->
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
" jukebox row pos=${row.position} id=${row.trackId} title='${row.trackName}' " +
|
||||||
|
"artist='${row.artistName}' durationMs=${row.durationMs}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (tracks.size > 12) {
|
||||||
|
Log.d(TAG, " … ${tracks.size - 12} more rows omitted from log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun matchPlaylistTracks(
|
||||||
|
playlistId: String,
|
||||||
|
tracks: List<TrackRow>,
|
||||||
|
pool: MutableList<Mp3FolderCandidate>,
|
||||||
|
mp3Total: Int,
|
||||||
|
): FolderPairingResult {
|
||||||
|
if (pool.isEmpty()) {
|
||||||
|
Log.w(TAG, "No MP3 candidates after scan — cannot pair")
|
||||||
|
return FolderPairingResult(
|
||||||
|
paired = 0,
|
||||||
|
failed = 0,
|
||||||
|
jukeboxRowCount = tracks.size,
|
||||||
|
mp3Count = mp3Total,
|
||||||
|
skippedNoSpotifyTrackId = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"pool id3 coverage: ${pool.count { it.id3Title != null || it.id3Artist != null }}/${pool.size} " +
|
||||||
|
"files have title or artist from tags",
|
||||||
|
)
|
||||||
|
|
||||||
|
var paired = 0
|
||||||
|
var failed = 0
|
||||||
|
var skippedNoSpotifyTrackId = 0
|
||||||
|
for (track in tracks.sortedBy { it.position }) {
|
||||||
|
val tid = track.trackId
|
||||||
|
if (tid.isNullOrBlank()) {
|
||||||
|
skippedNoSpotifyTrackId++
|
||||||
|
failed++
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"skip row pos=${track.position}: no Spotify track id " +
|
||||||
|
"(title='${track.trackName}' artist='${track.artistName}')",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val title = track.trackName.orEmpty()
|
||||||
|
val artist = track.artistName.orEmpty()
|
||||||
|
val pick = TrackFileMatching.bestMatchForFolder(title, artist, pool)
|
||||||
|
if (pick == null) {
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val err =
|
||||||
|
withContext(Dispatchers.IO) { AudioUriValidator.validateReadableAudio(context, pick) }
|
||||||
|
if (err != null) {
|
||||||
|
Log.w(TAG, "match candidate failed validation spotifyTid=$tid local=$pick err=$err")
|
||||||
|
upsertPairing(playlistId, tid, null, err)
|
||||||
|
failed++
|
||||||
|
} else {
|
||||||
|
upsertPairing(playlistId, tid, pick.toString(), null)
|
||||||
|
paired++
|
||||||
|
pool.removeAll { it.uri == pick }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"playlist pairing done paired=$paired failed=$failed " +
|
||||||
|
"skippedNoSpotifyTrackId=$skippedNoSpotifyTrackId jukeboxRows=${tracks.size} mp3=$mp3Total",
|
||||||
|
)
|
||||||
|
return FolderPairingResult(
|
||||||
|
paired = paired,
|
||||||
|
failed = failed,
|
||||||
|
jukeboxRowCount = tracks.size,
|
||||||
|
mp3Count = mp3Total,
|
||||||
|
skippedNoSpotifyTrackId = skippedNoSpotifyTrackId,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package at.lockstep.player.ui.pairing
|
package at.lockstep.player.ui.pairing
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -20,6 +21,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -33,10 +35,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import at.lockstep.jukebox.db.TrackRow
|
import at.lockstep.jukebox.db.TrackRow
|
||||||
|
import at.lockstep.player.FolderPairingResult
|
||||||
import at.lockstep.player.LockstepViewModel
|
import at.lockstep.player.LockstepViewModel
|
||||||
import at.lockstep.player.R
|
import at.lockstep.player.R
|
||||||
|
import at.lockstep.player.util.AudioReadPermission
|
||||||
import at.lockstep.player.util.SafInitialUris
|
import at.lockstep.player.util.SafInitialUris
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -79,54 +84,44 @@ fun PairingScreen(
|
|||||||
onResult = { tree ->
|
onResult = { tree ->
|
||||||
if (tree != null) {
|
if (tree != null) {
|
||||||
viewModel.pairPlaylistFromFolder(playlistId, tree) { r ->
|
viewModel.pairPlaylistFromFolder(playlistId, tree) { r ->
|
||||||
when {
|
showPairingResultToast(context, r)
|
||||||
r.mp3Count == 0 ->
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.pairing_no_mp3_in_folder),
|
|
||||||
Toast.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
|
|
||||||
r.jukeboxRowCount == 0 ->
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.pairing_jukebox_empty),
|
|
||||||
Toast.LENGTH_LONG,
|
|
||||||
).show()
|
|
||||||
|
|
||||||
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.pairing_all_missing_spotify_id),
|
|
||||||
Toast.LENGTH_LONG,
|
|
||||||
).show()
|
|
||||||
|
|
||||||
r.paired == 0 && r.failed == 0 ->
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.pairing_no_mp3_in_folder),
|
|
||||||
Toast.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
|
|
||||||
r.failed > 0 ->
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
|
|
||||||
Toast.LENGTH_LONG,
|
|
||||||
).show()
|
|
||||||
|
|
||||||
else ->
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.pairing_folder_ok, r.paired),
|
|
||||||
Toast.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val runDeviceScan = {
|
||||||
|
viewModel.pairPlaylistFromDeviceAudio(playlistId) { r ->
|
||||||
|
showPairingResultToast(context, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
onResult = { granted ->
|
||||||
|
if (granted) {
|
||||||
|
runDeviceScan()
|
||||||
|
} else {
|
||||||
|
Toast
|
||||||
|
.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.pairing_audio_permission_denied),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun scanDeviceAudio() {
|
||||||
|
val permission = AudioReadPermission.permissionName()
|
||||||
|
when {
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ->
|
||||||
|
runDeviceScan()
|
||||||
|
else -> audioPermissionLauncher.launch(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -150,7 +145,22 @@ fun PairingScreen(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
onClick = { openTree.launch(SafInitialUris.internalDocuments()) },
|
onClick = { scanDeviceAudio() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(context.getString(R.string.pairing_scan_device))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.pairing_scan_device_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { openTree.launch(SafInitialUris.internalDocuments(context)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(context.getString(R.string.pairing_choose_folder))
|
Text(context.getString(R.string.pairing_choose_folder))
|
||||||
@@ -216,3 +226,52 @@ fun PairingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showPairingResultToast(
|
||||||
|
context: android.content.Context,
|
||||||
|
r: FolderPairingResult,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
r.mp3Count == 0 ->
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.pairing_no_mp3_in_folder),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
|
||||||
|
r.jukeboxRowCount == 0 ->
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.pairing_jukebox_empty),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
|
||||||
|
r.skippedNoSpotifyTrackId == r.jukeboxRowCount && r.jukeboxRowCount > 0 ->
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.pairing_all_missing_spotify_id),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
|
||||||
|
r.paired == 0 && r.failed == 0 ->
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.pairing_no_mp3_in_folder),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
|
||||||
|
r.failed > 0 ->
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.pairing_folder_mixed_result, r.paired, r.failed),
|
||||||
|
Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
|
||||||
|
else ->
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.pairing_folder_ok, r.paired),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
object AudioReadPermission {
|
||||||
|
fun permissionName(): String =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Manifest.permission.READ_MEDIA_AUDIO
|
||||||
|
} else {
|
||||||
|
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object MediaStoreMp3Scanner {
|
||||||
|
private const val TAG = "LockstepPairing"
|
||||||
|
|
||||||
|
fun listMp3Candidates(context: Context): List<Mp3FolderCandidate> {
|
||||||
|
val resolver = context.applicationContext.contentResolver
|
||||||
|
val collection =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
|
||||||
|
val projection =
|
||||||
|
arrayOf(
|
||||||
|
MediaStore.Audio.Media._ID,
|
||||||
|
MediaStore.Audio.Media.DISPLAY_NAME,
|
||||||
|
MediaStore.Audio.Media.TITLE,
|
||||||
|
MediaStore.Audio.Media.ARTIST,
|
||||||
|
)
|
||||||
|
|
||||||
|
val selection =
|
||||||
|
"(${MediaStore.Audio.Media.MIME_TYPE} = ? OR " +
|
||||||
|
"${MediaStore.Audio.Media.DISPLAY_NAME} LIKE ?)"
|
||||||
|
val selectionArgs = arrayOf("audio/mpeg", "%.mp3")
|
||||||
|
|
||||||
|
val out = mutableListOf<Mp3FolderCandidate>()
|
||||||
|
resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor ->
|
||||||
|
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||||
|
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||||
|
val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
|
||||||
|
val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val id = cursor.getLong(idCol)
|
||||||
|
val displayName = cursor.getString(nameCol).orEmpty()
|
||||||
|
val title = cursor.getString(titleCol)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val artist = cursor.getString(artistCol)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
out.add(
|
||||||
|
Mp3FolderCandidate(
|
||||||
|
uri = ContentUris.withAppendedId(collection, id),
|
||||||
|
fileBaseName = displayName.substringBeforeLast('.'),
|
||||||
|
id3Title = title,
|
||||||
|
id3Artist = artist,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: Log.w(TAG, "MediaStore audio query returned null")
|
||||||
|
|
||||||
|
Log.d(TAG, "MediaStoreMp3Scanner found mp3Count=${out.size}")
|
||||||
|
return out.distinctBy { it.uri }.sortedBy { it.fileBaseName.lowercase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,41 @@
|
|||||||
package at.lockstep.player.util
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.storage.StorageManager
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
|
||||||
object SafInitialUris {
|
object SafInitialUris {
|
||||||
private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents"
|
private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents"
|
||||||
|
private const val INITIAL_URI_EXTRA = "android.provider.extra.INITIAL_URI"
|
||||||
|
|
||||||
/** Internal-storage Documents — avoids the blocked volume root shown on Pixel devices. */
|
/**
|
||||||
fun internalDocuments(): Uri =
|
* Internal-storage Documents. Uses [StorageManager] on Android 10+ so the system picker
|
||||||
DocumentsContract.buildDocumentUri(
|
* lands in a choosable folder instead of the blocked volume root on Pixel devices.
|
||||||
|
*/
|
||||||
|
fun internalDocuments(context: Context): Uri {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val storageManager = context.getSystemService(StorageManager::class.java)
|
||||||
|
val intent = storageManager.primaryStorageVolume.createOpenDocumentTreeIntent()
|
||||||
|
val root =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(INITIAL_URI_EXTRA, Uri::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra(INITIAL_URI_EXTRA)
|
||||||
|
}
|
||||||
|
if (root != null) {
|
||||||
|
val scheme =
|
||||||
|
root
|
||||||
|
.toString()
|
||||||
|
.replace("/root/", "/document/") + "%3A" + "Documents"
|
||||||
|
return Uri.parse(scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DocumentsContract.buildDocumentUri(
|
||||||
EXTERNAL_STORAGE_AUTHORITY,
|
EXTERNAL_STORAGE_AUTHORITY,
|
||||||
"primary:Documents",
|
"primary:Documents",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
|
|
||||||
<string name="pairing_title">Pair local MP3s</string>
|
<string name="pairing_title">Pair local MP3s</string>
|
||||||
<string name="pairing_choose_folder">Choose folder of MP3s</string>
|
<string name="pairing_choose_folder">Choose folder of MP3s</string>
|
||||||
|
<string name="pairing_scan_device">Scan audio on this device</string>
|
||||||
|
<string name="pairing_scan_device_help">Finds MP3s in Music and other folders without using the folder picker.</string>
|
||||||
|
<string name="pairing_audio_permission_denied">Audio permission is required to scan MP3s on this device.</string>
|
||||||
<string name="pairing_status_paired">Paired with local file</string>
|
<string name="pairing_status_paired">Paired with local file</string>
|
||||||
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
|
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
|
||||||
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
|
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user