name the codec, not the container, and tidy the info rows

MediaExtractor reports audio/raw for some containers it decodes itself, so a
downloaded FLAC showed "Raw" while the same stream showed FLAC. The file's own
header is now the source of the format when the extractor gives nothing useful,
covering FLAC, MP3, AAC/ALAC, Ogg, Vorbis, Opus, WAV and WebM - and for the
containers, the codec inside them (a WebM is Opus or Vorbis, not "WebM"),
matching what media3 reports for the stream.

The info dialog's label column is a fixed width in the accent colour at a
smaller size, so the values line up. The quality rows read format then bitrate,
the manage row loses its subtitle, and the switches get explicit off colours,
since the themed defaults left the knob invisible against some backgrounds.
This commit is contained in:
austinried
2026-10-04 00:30:04 +00:00
parent caf8369924
commit eccf14aa58
5 changed files with 113 additions and 7 deletions
@@ -16,7 +16,7 @@ suspend fun readAudioEncoding(file: File): AudioEncoding? =
if (extractor.trackCount == 0) return@runCatching null if (extractor.trackCount == 0) return@runCatching null
val format = extractor.getTrackFormat(0) val format = extractor.getTrackFormat(0)
AudioEncoding( AudioEncoding(
mimeType = format.stringOrNull(MediaFormat.KEY_MIME), mimeType = format.stringOrNull(MediaFormat.KEY_MIME).orGeneric(file),
bitrate = format.intOrNull(MediaFormat.KEY_BIT_RATE), bitrate = format.intOrNull(MediaFormat.KEY_BIT_RATE),
sampleRate = format.intOrNull(MediaFormat.KEY_SAMPLE_RATE), sampleRate = format.intOrNull(MediaFormat.KEY_SAMPLE_RATE),
channels = format.intOrNull(MediaFormat.KEY_CHANNEL_COUNT), channels = format.intOrNull(MediaFormat.KEY_CHANNEL_COUNT),
@@ -27,6 +27,50 @@ suspend fun readAudioEncoding(file: File): AudioEncoding? =
}.getOrNull() }.getOrNull()
} }
// The extractor reports audio/raw for some containers it decodes itself (a FLAC, say), which reads
// as "Raw" and hides the codec; the file's own header names it.
private fun String?.orGeneric(file: File): String? =
if (isNullOrBlank() ||
this in GENERIC_MIME_TYPES
) {
sniffAudioMime(file) ?: this
} else {
this
}
internal fun sniffAudioMime(file: File): String? =
runCatching {
val head = ByteArray(SNIFF_BYTES)
val read = file.inputStream().use { it.read(head) }
val text = String(head, 0, read.coerceAtLeast(0), Charsets.ISO_8859_1)
when {
text.startsWith("fLaC") -> "audio/flac"
text.startsWith("OggS") && text.contains("OpusHead") -> "audio/opus"
text.startsWith("OggS") && text.contains("vorbis") -> "audio/vorbis"
text.startsWith("OggS") -> "audio/ogg"
text.startsWith("RIFF") -> "audio/wav"
text.startsWith("ID3") -> "audio/mpeg"
read >= 2 && head[0] == 0xFF.toByte() && (head[1].toInt() and 0xE0) == 0xE0 -> "audio/mpeg"
read >= 8 && text.regionMatches(4, "ftyp", 0, 4) -> if (text.contains("alac")) "audio/alac" else "audio/mp4"
text.startsWith(EBML_MAGIC) -> webmCodec(text)
else -> null
}
}.getOrNull()
// WebM and MP4 are containers; the codec they carry is named in the track description near the
// start, and media3 reports that inner codec for a stream, so match it here.
private fun webmCodec(text: String): String =
when {
text.contains("A_OPUS") -> "audio/opus"
text.contains("A_VORBIS") -> "audio/vorbis"
text.contains("A_FLAC") -> "audio/flac"
else -> "audio/webm"
}
private fun MediaFormat.stringOrNull(key: String): String? = if (containsKey(key)) getString(key) else null 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 private fun MediaFormat.intOrNull(key: String): Int? = if (containsKey(key)) getInteger(key) else null
private const val SNIFF_BYTES = 1024
private const val EBML_MAGIC = "\u001A\u0045\u00DF\u00A3"
private val GENERIC_MIME_TYPES = setOf("audio/raw", "audio/x-raw", "application/octet-stream", "audio/pcm")
@@ -12,8 +12,10 @@ data class AudioEncoding(
null -> null null -> null
"audio/mpeg" -> "MP3" "audio/mpeg" -> "MP3"
"audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac" -> "AAC" "audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac" -> "AAC"
"audio/alac" -> "ALAC"
"audio/flac", "audio/x-flac" -> "FLAC" "audio/flac", "audio/x-flac" -> "FLAC"
"audio/ogg" -> "Ogg" "audio/ogg" -> "Ogg"
"audio/vorbis" -> "Vorbis"
"audio/opus" -> "Opus" "audio/opus" -> "Opus"
"audio/webm" -> "WebM" "audio/webm" -> "WebM"
"audio/wav", "audio/x-wav" -> "WAV" "audio/wav", "audio/x-wav" -> "WAV"
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -79,9 +80,9 @@ private fun InfoRow(
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 16.dp), modifier = Modifier.width(INFO_LABEL_WIDTH),
) )
Text( Text(
text = value, text = value,
@@ -91,6 +92,8 @@ private fun InfoRow(
} }
} }
private val INFO_LABEL_WIDTH = 104.dp
private fun downloadStatus(status: DownloadStatus): String = private fun downloadStatus(status: DownloadStatus): String =
when (status) { when (status) {
DownloadStatus.Completed -> "Downloaded" DownloadStatus.Completed -> "Downloaded"
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text 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
@@ -210,7 +211,16 @@ fun SettingsScreen(
headlineContent = { Text("Download over mobile data") }, headlineContent = { Text("Download over mobile data") },
supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") }, supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") },
trailingContent = { trailingContent = {
Switch(checked = downloadOverMetered, onCheckedChange = onDownloadOverMeteredChange) Switch(
checked = downloadOverMetered,
onCheckedChange = onDownloadOverMeteredChange,
colors =
SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
),
)
}, },
modifier = Modifier.clickable { onDownloadOverMeteredChange(!downloadOverMetered) }, modifier = Modifier.clickable { onDownloadOverMeteredChange(!downloadOverMetered) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -219,7 +229,6 @@ fun SettingsScreen(
item { item {
ListItem( ListItem(
headlineContent = { Text("Manage downloads") }, headlineContent = { Text("Manage downloads") },
supportingContent = { Text("Space and deletion") },
modifier = Modifier.clickable(onClick = onOpenDownloads), modifier = Modifier.clickable(onClick = onOpenDownloads),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
@@ -270,7 +279,7 @@ fun SettingsScreen(
} }
} }
private fun qualityLabel(quality: StreamQuality): String = "${bitrateLabel(quality.maxBitrate)} · ${quality.format ?: "Server default"}" private fun qualityLabel(quality: StreamQuality): String = "${quality.format ?: "Server default"} · ${bitrateLabel(quality.maxBitrate)}"
private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps" private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps"
@@ -0,0 +1,48 @@
package com.subtracks.data.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.File
class AudioEncodingReaderTest {
@Test
fun containerHeadersNameTheCommonCodecs() {
assertEquals("audio/flac", sniff("fLaC\u0000\u0000\u0000\u0022"))
assertEquals("audio/mpeg", sniff("ID3\u0004\u0000\u0000"))
assertEquals("audio/mpeg", sniff("\u00FF\u00FB\u0090\u0000"))
assertEquals("audio/wav", sniff("RIFF\u0000\u0000\u0000\u0000WAVE"))
assertEquals("audio/mp4", sniff("\u0000\u0000\u0000\u001cftypM4A "))
assertEquals("audio/webm", sniff("\u001A\u0045\u00DF\u00A3"))
}
@Test
fun aContainerIsNotTheCodecInsideIt() {
assertEquals("audio/opus", sniff("\u001A\u0045\u00DF\u00A3webm\u0000A_OPUS\u0000"))
assertEquals("audio/vorbis", sniff("\u001A\u0045\u00DF\u00A3webm\u0000A_VORBIS\u0000"))
assertEquals("audio/webm", sniff("\u001A\u0045\u00DF\u00A3webm\u0000"))
assertEquals("audio/alac", sniff("\u0000\u0000\u0000\u001cftypM4A \u0000\u0000\u0000\u0000alac"))
}
@Test
fun anOggPageNamesTheCodecInsideIt() {
assertEquals("audio/opus", sniff("OggS\u0000\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000OpusHead"))
assertEquals("audio/vorbis", sniff("OggS\u0000\u0002\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0001vorbis"))
assertEquals("audio/ogg", sniff("OggS\u0000\u0002\u0000\u0000"))
}
@Test
fun anythingElseIsUnknown() {
assertNull(sniff("<?xml version=\"1.0\"?><subsonic-response/>"))
assertNull(sniff(""))
}
private fun sniff(contents: String): String? =
File.createTempFile("audio", ".tmp").apply { writeBytes(contents.toByteArray(Charsets.ISO_8859_1)) }.let {
try {
sniffAudioMime(it)
} finally {
it.delete()
}
}
}