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() + } + } +}