diff --git a/BUGS.md b/BUGS.md index b988302..a3ef43f 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,5 +1,7 @@ # 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 - "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." diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d69ff97..d31a3c5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index 9ec592d..68edb60 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -13,6 +13,7 @@ import at.lockstep.player.util.AudioUriValidator import at.lockstep.player.playback.TrackBoundaryEvent import at.lockstep.player.util.BeatAnnotationStorage import at.lockstep.player.util.FolderMp3Scanner +import at.lockstep.player.util.MediaStoreMp3Scanner import at.lockstep.player.util.Mp3EmbeddedMetadata import at.lockstep.player.util.Mp3FolderCandidate import at.lockstep.player.util.TrackFileMatching @@ -293,25 +294,7 @@ class LockstepViewModel( } val tracks = loadJukeboxTracksEnsuringDetail(playlistId) - 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") - } - + logJukeboxTracks(tracks) val (mp3Total, pool) = withContext(Dispatchers.IO) { val mp3s = FolderMp3Scanner.listMp3Uris(context, treeUri) @@ -328,75 +311,117 @@ class LockstepViewModel( }.toMutableList() Pair(mp3s.size, p) } - - 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, - ), - ) + onFinished(matchPlaylistTracks(playlistId, tracks, pool, mp3Total)) } } + + /** 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) { + 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, + pool: MutableList, + 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, + ) + } } diff --git a/app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt b/app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt index 9367f4e..d54b47a 100644 --- a/app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/pairing/PairingScreen.kt @@ -1,5 +1,6 @@ package at.lockstep.player.ui.pairing +import android.content.pm.PackageManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -20,6 +21,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -33,10 +35,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import at.lockstep.jukebox.db.TrackRow +import at.lockstep.player.FolderPairingResult import at.lockstep.player.LockstepViewModel import at.lockstep.player.R +import at.lockstep.player.util.AudioReadPermission import at.lockstep.player.util.SafInitialUris @OptIn(ExperimentalMaterial3Api::class) @@ -79,54 +84,44 @@ fun PairingScreen( onResult = { tree -> if (tree != null) { viewModel.pairPlaylistFromFolder(playlistId, tree) { r -> - 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() - } + showPairingResultToast(context, r) } } }, ) + 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( modifier = modifier.fillMaxSize(), topBar = { @@ -150,7 +145,22 @@ fun PairingScreen( ) { item { 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(), ) { 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() + } +} diff --git a/app/src/main/java/at/lockstep/player/util/AudioReadPermission.kt b/app/src/main/java/at/lockstep/player/util/AudioReadPermission.kt new file mode 100644 index 0000000..5cc34b4 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/AudioReadPermission.kt @@ -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 + } +} diff --git a/app/src/main/java/at/lockstep/player/util/MediaStoreMp3Scanner.kt b/app/src/main/java/at/lockstep/player/util/MediaStoreMp3Scanner.kt new file mode 100644 index 0000000..10b244b --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/MediaStoreMp3Scanner.kt @@ -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 { + 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() + 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() } + } +} diff --git a/app/src/main/java/at/lockstep/player/util/SafInitialUris.kt b/app/src/main/java/at/lockstep/player/util/SafInitialUris.kt index 11b2e21..7c00587 100644 --- a/app/src/main/java/at/lockstep/player/util/SafInitialUris.kt +++ b/app/src/main/java/at/lockstep/player/util/SafInitialUris.kt @@ -1,15 +1,41 @@ package at.lockstep.player.util +import android.content.Context import android.net.Uri +import android.os.Build +import android.os.storage.StorageManager import android.provider.DocumentsContract object SafInitialUris { 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 = - DocumentsContract.buildDocumentUri( + /** + * Internal-storage Documents. Uses [StorageManager] on Android 10+ so the system picker + * 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, "primary:Documents", ) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e6b873..f6e450d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,9 @@ Pair local MP3s Choose folder of MP3s + Scan audio on this device + Finds MP3s in Music and other folders without using the folder picker. + Audio permission is required to scan MP3s on this device. Paired with local file Not paired — tap to pick an MP3 No MP3 files found in that folder.