mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user