diff --git a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt index 7cc8e7a3..5ed2b482 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -104,7 +104,7 @@ interface DownloadDao { fun playlistStatuses(sourceId: Long): Flow> @Query( - "SELECT d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " + + "SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " + "al.artistId AS artistId, ar.name AS artistName, d.status AS status, d.total AS size " + "FROM song_downloads d " + "JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + diff --git a/app/src/main/kotlin/com/subtracks/data/media/AudioEncodingReader.kt b/app/src/main/kotlin/com/subtracks/data/media/AudioEncodingReader.kt new file mode 100644 index 00000000..6b97c222 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/media/AudioEncodingReader.kt @@ -0,0 +1,32 @@ +package com.subtracks.data.media + +import android.media.MediaExtractor +import android.media.MediaFormat +import com.subtracks.data.model.AudioEncoding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +suspend fun readAudioEncoding(file: File): AudioEncoding? = + withContext(Dispatchers.IO) { + runCatching { + val extractor = MediaExtractor() + try { + extractor.setDataSource(file.absolutePath) + if (extractor.trackCount == 0) return@runCatching null + val format = extractor.getTrackFormat(0) + AudioEncoding( + mimeType = format.stringOrNull(MediaFormat.KEY_MIME), + bitrate = format.intOrNull(MediaFormat.KEY_BIT_RATE), + sampleRate = format.intOrNull(MediaFormat.KEY_SAMPLE_RATE), + channels = format.intOrNull(MediaFormat.KEY_CHANNEL_COUNT), + ) + } finally { + extractor.release() + } + }.getOrNull() + } + +private fun MediaFormat.stringOrNull(key: String): String? = if (containsKey(key)) getString(key) else null + +private fun MediaFormat.intOrNull(key: String): Int? = if (containsKey(key)) getInteger(key) else null diff --git a/app/src/main/kotlin/com/subtracks/data/model/AudioEncoding.kt b/app/src/main/kotlin/com/subtracks/data/model/AudioEncoding.kt new file mode 100644 index 00000000..28480b1f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/model/AudioEncoding.kt @@ -0,0 +1,22 @@ +package com.subtracks.data.model + +data class AudioEncoding( + val mimeType: String?, + val bitrate: Int? = null, + val sampleRate: Int? = null, + val channels: Int? = null, +) { + val format: String? + get() = + when (mimeType?.lowercase()) { + null -> null + "audio/mpeg" -> "MP3" + "audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac" -> "AAC" + "audio/flac", "audio/x-flac" -> "FLAC" + "audio/ogg" -> "Ogg" + "audio/opus" -> "Opus" + "audio/webm" -> "WebM" + "audio/wav", "audio/x-wav" -> "WAV" + else -> mimeType.substringAfterLast('/').uppercase() + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index 4d98bc58..cdc03319 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -63,6 +63,7 @@ data class EntityDownloadStatus( data class DownloadedSong( val songId: String, + val sourceId: Long, val title: String, val albumId: String?, val albumName: String?, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index 970e7fda..e68ce93f 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -93,12 +93,17 @@ class DownloadRepository( if (started) loaded.await() } - fun localUri(songId: String): String? = + fun localFile(songId: String): File? = statesFlow.value[songId] ?.takeIf { it.status == DownloadStatus.Completed } - ?.let { file(it.sourceId, it.songId) } - ?.takeIf { it.exists() } - ?.let { Uri.fromFile(it).toString() } + ?.let { localFile(it.sourceId, it.songId) } + + fun localFile( + sourceId: Long, + songId: String, + ): File? = file(sourceId, songId).takeIf { it.exists() } + + fun localUri(songId: String): String? = localFile(songId)?.let { Uri.fromFile(it).toString() } suspend fun download( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 649175ba..258d2b58 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -3,12 +3,15 @@ package com.subtracks.playback import android.os.Bundle import android.util.Log import androidx.media3.common.C +import androidx.media3.common.Format import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem.ClippingConfiguration import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController +import com.subtracks.data.model.AudioEncoding import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.SourceRepository @@ -38,6 +41,24 @@ class Media3PlayerHandle( override val currentItem: QueueItem? get() = controller.currentMediaItem?.let(::toQueueItem) + @get:UnstableApi + override val audioEncoding: AudioEncoding? + get() = + controller.currentTracks.groups + .firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.isSelected } + ?.let { group -> + (0 until group.length) + .firstOrNull { group.isTrackSelected(it) } + ?.let(group::getTrackFormat) + }?.let { format -> + AudioEncoding( + mimeType = format.sampleMimeType, + bitrate = format.bitrate.takeIf { it != Format.NO_VALUE }, + sampleRate = format.sampleRate.takeIf { it != Format.NO_VALUE }, + channels = format.channelCount.takeIf { it != Format.NO_VALUE }, + ) + } + override fun setWindow( items: List, startIndex: Int, diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 8daf7ad0..21325ffd 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -1,5 +1,6 @@ package com.subtracks.playback +import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKind @@ -118,6 +119,8 @@ class PlaybackController( private val _positionMs = MutableStateFlow(0L) val positionMs: StateFlow = _positionMs + fun currentAudioEncoding(): AudioEncoding? = player?.audioEncoding + private var player: PlayerHandle? = null private var connecting = false private var pendingPlay: PendingPlay? = null diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt index a3ef4df1..b397f7b4 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt @@ -1,5 +1,7 @@ package com.subtracks.playback +import com.subtracks.data.model.AudioEncoding + interface PlayerHandle { val itemCount: Int val currentIndex: Int @@ -12,6 +14,9 @@ interface PlayerHandle { val currentPositionMs: Long val currentItem: QueueItem? + /** The encoding of the audio now being rendered, when the player knows it. */ + val audioEncoding: AudioEncoding? get() = null + fun setWindow( items: List, startIndex: Int, diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 8532130f..523289ca 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -58,12 +58,15 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.subtracks.data.model.Song +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ItemContextMenu import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.components.SongInfoDialog import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.downloads.DownloadsRoute import com.subtracks.ui.library.AlbumDetailRoute @@ -175,7 +178,9 @@ private fun MainNavigation() { val tabBarVisible = currentRoute == Routes.LIBRARY val playbackController = koinInject() val libraryRepository = koinInject() + val downloadRepository = koinInject() val playback by playbackController.state.collectAsStateWithLifecycle() + val downloads by downloadRepository.states().collectAsStateWithLifecycle() var showingQueue by rememberSaveable { mutableStateOf(false) } var nowPlayingOpen by rememberSaveable { mutableStateOf(false) } var nowPlayingProgress by remember { mutableFloatStateOf(0f) } @@ -184,6 +189,7 @@ private fun MainNavigation() { var settleJob by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val contextMenuHost = remember { ContextMenuHost() } + var infoSong by remember { mutableStateOf(null) } fun settleNowPlaying(open: Boolean) { if (open) { @@ -502,7 +508,22 @@ private fun MainNavigation() { } contextMenuHost.target?.let { target -> - ItemContextMenu(target = target, actions = contextMenuHost.actions, onDismiss = { contextMenuHost.dismiss() }) + ItemContextMenu( + target = target, + actions = contextMenuHost.actions, + onDismiss = { contextMenuHost.dismiss() }, + onInfo = { infoSong = it }, + ) + } + + infoSong?.let { song -> + SongInfoDialog( + song = song, + download = downloads[song.id], + localFile = downloadRepository.localFile(song.id), + streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id }, + onDismiss = { infoSong = null }, + ) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index 2949e22d..02838c6d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Cancel import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.Star @@ -164,6 +165,7 @@ fun ItemContextMenu( target: MenuTarget, actions: ItemActions, onDismiss: () -> Unit, + onInfo: ((SongModel) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() @@ -219,6 +221,9 @@ fun ItemContextMenu( } } } + onInfo?.let { info -> + MenuItem(Icons.Rounded.Info, "Info") { dismiss { info(target.song) } } + } } is MenuTarget.Album -> { diff --git a/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt new file mode 100644 index 00000000..09727c0e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt @@ -0,0 +1,100 @@ +package com.subtracks.ui.components + +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.subtracks.data.media.readAudioEncoding +import com.subtracks.data.model.AudioEncoding +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.Song +import com.subtracks.data.model.SongDownload +import java.io.File + +@Composable +fun SongInfoDialog( + song: Song, + download: SongDownload?, + localFile: File?, + streamEncoding: AudioEncoding?, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var encoding by remember(song.id, localFile) { mutableStateOf(null) } + LaunchedEffect(song.id, localFile) { + encoding = localFile?.let { readAudioEncoding(it) } + } + val shown = if (download?.status == DownloadStatus.Completed) encoding else streamEncoding + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Info") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + InfoRow("Title", song.title) + InfoRow("Artist", song.artist) + InfoRow("Album", song.album) + InfoRow("Duration", song.duration?.let(DateUtils::formatElapsedTime)) + InfoRow("Track", song.track?.let { track -> song.disc?.let { "$it.$track" } ?: track.toString() }) + InfoRow("Genre", song.genre) + InfoRow("Starred", if (song.starred != null) "Yes" else null) + if (download != null) { + InfoRow("Download", downloadStatus(download.status)) + InfoRow("Size", localFile?.length()?.takeIf { it > 0 }?.let { Formatter.formatFileSize(context, it) }) + download.error?.let { InfoRow("Error", it) } + } + shown?.let { enc -> + InfoRow("Format", enc.format) + InfoRow("Bitrate", enc.bitrate?.takeIf { it > 0 }?.let { "${it / 1000} kbps" }) + InfoRow("Sample rate", enc.sampleRate?.takeIf { it > 0 }?.let { "$it Hz" }) + InfoRow("Channels", enc.channels?.takeIf { it > 0 }?.toString()) + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} + +@Composable +private fun InfoRow( + label: String, + value: String?, +) { + if (value.isNullOrBlank()) return + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } +} + +private fun downloadStatus(status: DownloadStatus): String = + when (status) { + DownloadStatus.Completed -> "Downloaded" + DownloadStatus.Queued -> "Queued" + DownloadStatus.Running -> "Downloading" + DownloadStatus.Failed -> "Failed" + } diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt index 6feb4bc3..924c7abd 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,6 +38,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadedSong @@ -68,6 +70,7 @@ fun DownloadsRoute( var pending by remember { mutableStateOf(null) } DownloadsScreen( tree = tree, + loadEncoding = viewModel::encoding, onBack = onBack, onDelete = { target -> when (target) { @@ -105,6 +108,7 @@ fun DownloadsRoute( @Composable fun DownloadsScreen( tree: DownloadTree, + loadEncoding: suspend (DownloadedSong) -> AudioEncoding?, onBack: () -> Unit, onDelete: (DownloadDeleteTarget) -> Unit, modifier: Modifier = Modifier, @@ -177,7 +181,7 @@ fun DownloadsScreen( } if (albumKey in expanded) { items(album.songs, key = { "$albumKey:${it.songId}" }) { song -> - SongRow(song) { onDelete(DownloadDeleteTarget.Songs(listOf(song.songId))) } + SongRow(song, loadEncoding) { onDelete(DownloadDeleteTarget.Songs(listOf(song.songId))) } } } } @@ -239,11 +243,16 @@ private fun NodeRow( @Composable private fun SongRow( song: DownloadedSong, + loadEncoding: suspend (DownloadedSong) -> AudioEncoding?, onDelete: () -> Unit, ) { + var encoding by remember(song.songId) { mutableStateOf(null) } + LaunchedEffect(song.songId, song.status) { + encoding = if (song.status == DownloadStatus.Completed) loadEncoding(song) else null + } ListItem( headlineContent = { Text(song.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - supportingContent = { Text(statusLabel(song), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = { Text(songSubtitle(song, encoding), maxLines = 1, overflow = TextOverflow.Ellipsis) }, trailingContent = { IconButton(onClick = onDelete) { Icon(Icons.Rounded.Delete, contentDescription = "Delete download") @@ -255,13 +264,31 @@ private fun SongRow( } @Composable -private fun statusLabel(song: DownloadedSong): String { +private fun songSubtitle( + song: DownloadedSong, + encoding: AudioEncoding?, +): String { val size = size(song.size) return when (song.status) { - DownloadStatus.Completed -> size - DownloadStatus.Queued -> "Queued · $size" - DownloadStatus.Running -> "Downloading · $size" - DownloadStatus.Failed -> "Failed · $size" + DownloadStatus.Completed -> { + listOfNotNull( + encoding?.format, + encoding?.bitrate?.takeIf { it > 0 }?.let { "${it / 1000} kbps" }, + size, + ).joinToString(" · ") + } + + DownloadStatus.Queued -> { + "Queued · $size" + } + + DownloadStatus.Running -> { + "Downloading · $size" + } + + DownloadStatus.Failed -> { + "Failed · $size" + } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt index 224fca11..3816d8e0 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt @@ -2,6 +2,8 @@ package com.subtracks.ui.downloads import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.subtracks.data.media.readAudioEncoding +import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadedSong import com.subtracks.data.repo.DownloadRepository @@ -76,6 +78,9 @@ class DownloadsViewModel( playbackController.refreshMediaItems() } } + + suspend fun encoding(song: DownloadedSong): AudioEncoding? = + downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) } } internal fun buildTree(songs: List): DownloadTree = diff --git a/app/src/test/kotlin/com/subtracks/data/model/AudioEncodingTest.kt b/app/src/test/kotlin/com/subtracks/data/model/AudioEncodingTest.kt new file mode 100644 index 00000000..f0beb8fa --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/model/AudioEncodingTest.kt @@ -0,0 +1,25 @@ +package com.subtracks.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AudioEncodingTest { + @Test + fun commonTypesGetReadableNames() { + assertEquals("MP3", AudioEncoding("audio/mpeg").format) + assertEquals("FLAC", AudioEncoding("audio/flac").format) + assertEquals("AAC", AudioEncoding("audio/mp4").format) + assertEquals("Opus", AudioEncoding("audio/opus").format) + } + + @Test + fun anUnknownTypeFallsBackToItsSubtype() { + assertEquals("X-CUSTOM", AudioEncoding("audio/x-custom").format) + } + + @Test + fun noTypeMeansNoFormat() { + assertNull(AudioEncoding(null).format) + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt index f4bc5023..3dfb5e6c 100644 --- a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadTreeTest.kt @@ -54,6 +54,7 @@ class DownloadTreeTest { size: Long, ) = DownloadedSong( songId = id, + sourceId = 1, title = title, albumId = albumId, albumName = albumName,