add a song info dialog and show the encoding in the manager

The song context menus gain Info, which lists every field we hold for the song
and, when it is downloaded, the file's format, bitrate, sample rate, channels
and size read from the file itself. For the song currently playing it takes the
encoding from the media3 stream instead, so a transcoded stream reports what is
actually being played. The menu is shared, so this reaches the album, playlist,
now playing and queue menus at once.

The downloads manager shows the same format and bitrate beside the size on each
song row, read lazily for the rows on screen rather than for the whole tree.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent adb9367f18
commit 02b3ddbb98
15 changed files with 286 additions and 13 deletions
@@ -104,7 +104,7 @@ interface DownloadDao {
fun playlistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@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 " +
@@ -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
@@ -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()
}
}
@@ -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?,
@@ -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,
@@ -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<QueueItem>,
startIndex: Int,
@@ -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<Long> = _positionMs
fun currentAudioEncoding(): AudioEncoding? = player?.audioEncoding
private var player: PlayerHandle? = null
private var connecting = false
private var pendingPlay: PendingPlay? = null
@@ -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<QueueItem>,
startIndex: Int,
@@ -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<PlaybackController>()
val libraryRepository = koinInject<LibraryRepository>()
val downloadRepository = koinInject<DownloadRepository>()
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<Job?>(null) }
val scope = rememberCoroutineScope()
val contextMenuHost = remember { ContextMenuHost() }
var infoSong by remember { mutableStateOf<Song?>(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 },
)
}
}
}
@@ -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 -> {
@@ -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<AudioEncoding?>(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"
}
@@ -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<DownloadDeleteTarget?>(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<AudioEncoding?>(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"
}
}
}
@@ -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<DownloadedSong>): DownloadTree =
@@ -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)
}
}
@@ -54,6 +54,7 @@ class DownloadTreeTest {
size: Long,
) = DownloadedSong(
songId = id,
sourceId = 1,
title = title,
albumId = albumId,
albumName = albumName,