From eccf14aa58fcc86dca4e226bf3b3a69a60ab4c72 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:28:58 +0000 Subject: [PATCH] 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. --- .../data/media/AudioEncodingReader.kt | 46 +++++++++++++++++- .../com/subtracks/data/model/AudioEncoding.kt | 2 + .../subtracks/ui/components/SongInfoDialog.kt | 9 ++-- .../subtracks/ui/settings/SettingsScreen.kt | 15 ++++-- .../data/media/AudioEncodingReaderTest.kt | 48 +++++++++++++++++++ 5 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/data/media/AudioEncodingReaderTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/media/AudioEncodingReader.kt b/app/src/main/kotlin/com/subtracks/data/media/AudioEncodingReader.kt index 6b97c222..d4cf52e6 100644 --- a/app/src/main/kotlin/com/subtracks/data/media/AudioEncodingReader.kt +++ b/app/src/main/kotlin/com/subtracks/data/media/AudioEncodingReader.kt @@ -16,7 +16,7 @@ suspend fun readAudioEncoding(file: File): AudioEncoding? = if (extractor.trackCount == 0) return@runCatching null val format = extractor.getTrackFormat(0) AudioEncoding( - mimeType = format.stringOrNull(MediaFormat.KEY_MIME), + mimeType = format.stringOrNull(MediaFormat.KEY_MIME).orGeneric(file), bitrate = format.intOrNull(MediaFormat.KEY_BIT_RATE), sampleRate = format.intOrNull(MediaFormat.KEY_SAMPLE_RATE), channels = format.intOrNull(MediaFormat.KEY_CHANNEL_COUNT), @@ -27,6 +27,50 @@ suspend fun readAudioEncoding(file: File): AudioEncoding? = }.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.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") diff --git a/app/src/main/kotlin/com/subtracks/data/model/AudioEncoding.kt b/app/src/main/kotlin/com/subtracks/data/model/AudioEncoding.kt index 28480b1f..ff45df8b 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/AudioEncoding.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/AudioEncoding.kt @@ -12,8 +12,10 @@ data class AudioEncoding( null -> null "audio/mpeg" -> "MP3" "audio/mp4", "audio/m4a", "audio/x-m4a", "audio/aac" -> "AAC" + "audio/alac" -> "ALAC" "audio/flac", "audio/x-flac" -> "FLAC" "audio/ogg" -> "Ogg" + "audio/vorbis" -> "Vorbis" "audio/opus" -> "Opus" "audio/webm" -> "WebM" "audio/wav", "audio/x-wav" -> "WAV" diff --git a/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt index 09727c0e..51d5d2ee 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/SongInfoDialog.kt @@ -7,6 +7,7 @@ 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.foundation.layout.width import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -79,9 +80,9 @@ private fun InfoRow( Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Text( text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.width(INFO_LABEL_WIDTH), ) Text( text = value, @@ -91,6 +92,8 @@ private fun InfoRow( } } +private val INFO_LABEL_WIDTH = 104.dp + private fun downloadStatus(status: DownloadStatus): String = when (status) { DownloadStatus.Completed -> "Downloaded" diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index f15eeff5..bd6baceb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -210,7 +211,16 @@ fun SettingsScreen( headlineContent = { Text("Download over mobile data") }, supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") }, 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) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), @@ -219,7 +229,6 @@ fun SettingsScreen( item { ListItem( headlineContent = { Text("Manage downloads") }, - supportingContent = { Text("Space and deletion") }, modifier = Modifier.clickable(onClick = onOpenDownloads), 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" diff --git a/app/src/test/kotlin/com/subtracks/data/media/AudioEncodingReaderTest.kt b/app/src/test/kotlin/com/subtracks/data/media/AudioEncodingReaderTest.kt new file mode 100644 index 00000000..964d6d8b --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/media/AudioEncodingReaderTest.kt @@ -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("")) + 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() + } + } +}