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>> fun playlistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@Query( @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 " + "al.artistId AS artistId, ar.name AS artistName, d.status AS status, d.total AS size " +
"FROM song_downloads d " + "FROM song_downloads d " +
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + "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( data class DownloadedSong(
val songId: String, val songId: String,
val sourceId: Long,
val title: String, val title: String,
val albumId: String?, val albumId: String?,
val albumName: String?, val albumName: String?,
@@ -93,12 +93,17 @@ class DownloadRepository(
if (started) loaded.await() if (started) loaded.await()
} }
fun localUri(songId: String): String? = fun localFile(songId: String): File? =
statesFlow.value[songId] statesFlow.value[songId]
?.takeIf { it.status == DownloadStatus.Completed } ?.takeIf { it.status == DownloadStatus.Completed }
?.let { file(it.sourceId, it.songId) } ?.let { localFile(it.sourceId, it.songId) }
?.takeIf { it.exists() }
?.let { Uri.fromFile(it).toString() } 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( suspend fun download(
sourceId: Long, sourceId: Long,
@@ -3,12 +3,15 @@ package com.subtracks.playback
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.ClippingConfiguration import androidx.media3.common.MediaItem.ClippingConfiguration
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
@@ -38,6 +41,24 @@ class Media3PlayerHandle(
override val currentItem: QueueItem? get() = controller.currentMediaItem?.let(::toQueueItem) 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( override fun setWindow(
items: List<QueueItem>, items: List<QueueItem>,
startIndex: Int, startIndex: Int,
@@ -1,5 +1,6 @@
package com.subtracks.playback package com.subtracks.playback
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
@@ -118,6 +119,8 @@ class PlaybackController(
private val _positionMs = MutableStateFlow(0L) private val _positionMs = MutableStateFlow(0L)
val positionMs: StateFlow<Long> = _positionMs val positionMs: StateFlow<Long> = _positionMs
fun currentAudioEncoding(): AudioEncoding? = player?.audioEncoding
private var player: PlayerHandle? = null private var player: PlayerHandle? = null
private var connecting = false private var connecting = false
private var pendingPlay: PendingPlay? = null private var pendingPlay: PendingPlay? = null
@@ -1,5 +1,7 @@
package com.subtracks.playback package com.subtracks.playback
import com.subtracks.data.model.AudioEncoding
interface PlayerHandle { interface PlayerHandle {
val itemCount: Int val itemCount: Int
val currentIndex: Int val currentIndex: Int
@@ -12,6 +14,9 @@ interface PlayerHandle {
val currentPositionMs: Long val currentPositionMs: Long
val currentItem: QueueItem? val currentItem: QueueItem?
/** The encoding of the audio now being rendered, when the player knows it. */
val audioEncoding: AudioEncoding? get() = null
fun setWindow( fun setWindow(
items: List<QueueItem>, items: List<QueueItem>,
startIndex: Int, startIndex: Int,
@@ -58,12 +58,15 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument 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.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.ItemContextMenu import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.SongInfoDialog
import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.downloads.DownloadsRoute import com.subtracks.ui.downloads.DownloadsRoute
import com.subtracks.ui.library.AlbumDetailRoute import com.subtracks.ui.library.AlbumDetailRoute
@@ -175,7 +178,9 @@ private fun MainNavigation() {
val tabBarVisible = currentRoute == Routes.LIBRARY val tabBarVisible = currentRoute == Routes.LIBRARY
val playbackController = koinInject<PlaybackController>() val playbackController = koinInject<PlaybackController>()
val libraryRepository = koinInject<LibraryRepository>() val libraryRepository = koinInject<LibraryRepository>()
val downloadRepository = koinInject<DownloadRepository>()
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
var showingQueue by rememberSaveable { mutableStateOf(false) } var showingQueue by rememberSaveable { mutableStateOf(false) }
var nowPlayingOpen by rememberSaveable { mutableStateOf(false) } var nowPlayingOpen by rememberSaveable { mutableStateOf(false) }
var nowPlayingProgress by remember { mutableFloatStateOf(0f) } var nowPlayingProgress by remember { mutableFloatStateOf(0f) }
@@ -184,6 +189,7 @@ private fun MainNavigation() {
var settleJob by remember { mutableStateOf<Job?>(null) } var settleJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val contextMenuHost = remember { ContextMenuHost() } val contextMenuHost = remember { ContextMenuHost() }
var infoSong by remember { mutableStateOf<Song?>(null) }
fun settleNowPlaying(open: Boolean) { fun settleNowPlaying(open: Boolean) {
if (open) { if (open) {
@@ -502,7 +508,22 @@ private fun MainNavigation() {
} }
contextMenuHost.target?.let { target -> 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.Cancel
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Download 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.Person
import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
@@ -164,6 +165,7 @@ fun ItemContextMenu(
target: MenuTarget, target: MenuTarget,
actions: ItemActions, actions: ItemActions,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onInfo: ((SongModel) -> Unit)? = null,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -219,6 +221,9 @@ fun ItemContextMenu(
} }
} }
} }
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, "Info") { dismiss { info(target.song) } }
}
} }
is MenuTarget.Album -> { 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.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.DownloadedSong
@@ -68,6 +70,7 @@ fun DownloadsRoute(
var pending by remember { mutableStateOf<DownloadDeleteTarget?>(null) } var pending by remember { mutableStateOf<DownloadDeleteTarget?>(null) }
DownloadsScreen( DownloadsScreen(
tree = tree, tree = tree,
loadEncoding = viewModel::encoding,
onBack = onBack, onBack = onBack,
onDelete = { target -> onDelete = { target ->
when (target) { when (target) {
@@ -105,6 +108,7 @@ fun DownloadsRoute(
@Composable @Composable
fun DownloadsScreen( fun DownloadsScreen(
tree: DownloadTree, tree: DownloadTree,
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
onBack: () -> Unit, onBack: () -> Unit,
onDelete: (DownloadDeleteTarget) -> Unit, onDelete: (DownloadDeleteTarget) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -177,7 +181,7 @@ fun DownloadsScreen(
} }
if (albumKey in expanded) { if (albumKey in expanded) {
items(album.songs, key = { "$albumKey:${it.songId}" }) { song -> 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 @Composable
private fun SongRow( private fun SongRow(
song: DownloadedSong, song: DownloadedSong,
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
onDelete: () -> Unit, 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( ListItem(
headlineContent = { Text(song.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, 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 = { trailingContent = {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete download") Icon(Icons.Rounded.Delete, contentDescription = "Delete download")
@@ -255,13 +264,31 @@ private fun SongRow(
} }
@Composable @Composable
private fun statusLabel(song: DownloadedSong): String { private fun songSubtitle(
song: DownloadedSong,
encoding: AudioEncoding?,
): String {
val size = size(song.size) val size = size(song.size)
return when (song.status) { return when (song.status) {
DownloadStatus.Completed -> size DownloadStatus.Completed -> {
DownloadStatus.Queued -> "Queued · $size" listOfNotNull(
DownloadStatus.Running -> "Downloading · $size" encoding?.format,
DownloadStatus.Failed -> "Failed · $size" 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.ViewModel
import androidx.lifecycle.viewModelScope 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.DownloadList
import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
@@ -76,6 +78,9 @@ class DownloadsViewModel(
playbackController.refreshMediaItems() playbackController.refreshMediaItems()
} }
} }
suspend fun encoding(song: DownloadedSong): AudioEncoding? =
downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) }
} }
internal fun buildTree(songs: List<DownloadedSong>): DownloadTree = 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, size: Long,
) = DownloadedSong( ) = DownloadedSong(
songId = id, songId = id,
sourceId = 1,
title = title, title = title,
albumId = albumId, albumId = albumId,
albumName = albumName, albumName = albumName,