diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt b/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt
index a4b1afd5f..ed4160ddc 100644
--- a/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/appinitializers/PlaybackInitializer.kt
@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.simplecityapps.mediaprovider.repository.songs.SongRepository
+import com.simplecityapps.playback.BitPerfectManager
import com.simplecityapps.playback.NoiseManager
import com.simplecityapps.playback.PlaybackManager
import com.simplecityapps.playback.PlaybackService
@@ -47,6 +48,7 @@ constructor(
@Suppress("unused") private val castSessionManager: CastSessionManager,
@Suppress("unused") private val mediaSessionManager: MediaSessionManager,
@Suppress("unused") private val noiseManager: NoiseManager,
+ @Suppress("unused") private val bitPerfectManager: BitPerfectManager,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope
) : AppInitializer,
QueueChangeCallback,
diff --git a/android/app/src/main/res/values/strings_settings.xml b/android/app/src/main/res/values/strings_settings.xml
index ff0970c93..6304454a8 100644
--- a/android/app/src/main/res/values/strings_settings.xml
+++ b/android/app/src/main/res/values/strings_settings.xml
@@ -138,6 +138,10 @@
Keep shuffle mode
When a new queue is selected, shuffle mode won\'t be disabled
+
+ Bit-perfect audio
+
+ Send audio directly to USB DACs without processing (Android 14+)
Day/Night
diff --git a/android/app/src/main/res/xml/preferences_playback.xml b/android/app/src/main/res/xml/preferences_playback.xml
index 953d3f140..7e1f76865 100644
--- a/android/app/src/main/res/xml/preferences_playback.xml
+++ b/android/app/src/main/res/xml/preferences_playback.xml
@@ -9,4 +9,11 @@
android:title="@string/pref_disable_shuffle_on_queue_title"
app:iconSpaceReserved="false" />
+
+
\ No newline at end of file
diff --git a/android/playback/src/main/AndroidManifest.xml b/android/playback/src/main/AndroidManifest.xml
index 658c03799..ec1dfe841 100644
--- a/android/playback/src/main/AndroidManifest.xml
+++ b/android/playback/src/main/AndroidManifest.xml
@@ -7,6 +7,9 @@
+
+
+
{
+ Log.d(TAG, "USB device attached")
+ checkAndConfigureBitPerfect()
+ }
+ UsbManager.ACTION_USB_DEVICE_DETACHED -> {
+ Log.d(TAG, "USB device detached")
+ disableBitPerfect()
+ }
+ AudioManager.ACTION_HEADSET_PLUG -> {
+ // Also monitor headset plug events which can indicate USB DAC connection
+ val state = intent.getIntExtra("state", -1)
+ if (state == 1) {
+ Log.d(TAG, "Headset plugged in")
+ checkAndConfigureBitPerfect()
+ } else if (state == 0) {
+ Log.d(TAG, "Headset unplugged")
+ disableBitPerfect()
+ }
+ }
+ }
+ }
+ }
+
+ init {
+ playbackWatcher.addCallback(this)
+ registerReceivers()
+
+ // Check if a USB DAC is already connected
+ checkAndConfigureBitPerfect()
+ }
+
+ private fun registerReceivers() {
+ val filter = IntentFilter().apply {
+ addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
+ addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
+ addAction(AudioManager.ACTION_HEADSET_PLUG)
+ }
+ context.registerReceiver(usbReceiver, filter)
+ }
+
+ fun cleanup() {
+ context.safelyUnregisterReceiver(usbReceiver)
+ disableBitPerfect()
+ }
+
+ private fun checkAndConfigureBitPerfect() {
+ if (!playbackPreferenceManager.bitPerfectEnabled) {
+ Log.d(TAG, "Bit-perfect mode disabled in preferences")
+ return
+ }
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ Log.d(TAG, "Bit-perfect mode requires Android 14+")
+ return
+ }
+
+ findUsbAudioDevice()?.let { device ->
+ configureBitPerfect(device)
+ } ?: run {
+ Log.d(TAG, "No USB audio device found")
+ disableBitPerfect()
+ }
+ }
+
+ private fun findUsbAudioDevice(): AudioDeviceInfo? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return null
+ }
+
+ val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
+ return devices.firstOrNull { device ->
+ device.type == AudioDeviceInfo.TYPE_USB_DEVICE ||
+ device.type == AudioDeviceInfo.TYPE_USB_HEADSET
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+ private fun configureBitPerfect(device: AudioDeviceInfo) {
+ try {
+ Log.d(TAG, "Configuring bit-perfect mode for device: ${device.productName}")
+
+ // Query supported mixer attributes for the USB device
+ val supportedAttributes = audioManager.getSupportedMixerAttributes(device)
+
+ if (supportedAttributes.isEmpty()) {
+ Log.w(TAG, "No supported mixer attributes for this device")
+ disableBitPerfect()
+ return
+ }
+
+ // Find a bit-perfect compatible configuration
+ // Prefer high sample rates and bit depths
+ val bitPerfectAttribute = supportedAttributes
+ .filter { it.mixerBehavior == AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT }
+ .maxByOrNull { attr ->
+ // Score by sample rate * channel count
+ (attr.format.sampleRate ?: 0) * (attr.format.channelCount ?: 0)
+ }
+
+ if (bitPerfectAttribute == null) {
+ Log.w(TAG, "Device doesn't support bit-perfect mode")
+ disableBitPerfect()
+ return
+ }
+
+ // Create AudioAttributes for media playback
+ val audioAttributes = android.media.AudioAttributes.Builder()
+ .setUsage(android.media.AudioAttributes.USAGE_MEDIA)
+ .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC)
+ .build()
+
+ // Set the preferred mixer attributes
+ val result = audioManager.setPreferredMixerAttributes(
+ audioAttributes,
+ device,
+ bitPerfectAttribute
+ )
+
+ if (result == AudioManager.SUCCESS) {
+ isActive = true
+ currentUsbDevice = device
+ Log.i(
+ TAG,
+ "Bit-perfect mode activated: ${bitPerfectAttribute.format.sampleRate}Hz, " +
+ "${bitPerfectAttribute.format.channelCount}ch, " +
+ "encoding=${bitPerfectAttribute.format.encoding}"
+ )
+
+ // Notify listeners that bit-perfect mode is active
+ notifyBitPerfectStateChanged(true, device)
+ } else {
+ Log.e(TAG, "Failed to set preferred mixer attributes")
+ disableBitPerfect()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error configuring bit-perfect mode", e)
+ disableBitPerfect()
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+ private fun disableBitPerfect() {
+ if (!isActive) {
+ return
+ }
+
+ try {
+ val audioAttributes = android.media.AudioAttributes.Builder()
+ .setUsage(android.media.AudioAttributes.USAGE_MEDIA)
+ .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC)
+ .build()
+
+ currentUsbDevice?.let { device ->
+ audioManager.clearPreferredMixerAttributes(audioAttributes, device)
+ Log.i(TAG, "Bit-perfect mode deactivated")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error clearing preferred mixer attributes", e)
+ } finally {
+ isActive = false
+ currentUsbDevice = null
+ notifyBitPerfectStateChanged(false, null)
+ }
+ }
+
+ private fun notifyBitPerfectStateChanged(active: Boolean, device: AudioDeviceInfo?) {
+ // TODO: Implement notification to UI layer
+ // This could broadcast an intent or use a callback mechanism
+ // to notify the UI that bit-perfect mode is active/inactive
+ Log.d(TAG, "Bit-perfect state changed: active=$active, device=${device?.productName}")
+ }
+
+ fun isActive(): Boolean = isActive
+
+ fun getCurrentDevice(): AudioDeviceInfo? = currentUsbDevice
+
+ // PlaybackWatcherCallback Implementation
+
+ override fun onPlaybackStateChanged(playbackState: PlaybackState) {
+ // When playback starts, re-check and configure if needed
+ when (playbackState) {
+ is PlaybackState.Playing -> {
+ if (!isActive && playbackPreferenceManager.bitPerfectEnabled) {
+ checkAndConfigureBitPerfect()
+ }
+ }
+ else -> {
+ // Keep bit-perfect active even when paused, so the configuration
+ // is ready when playback resumes
+ }
+ }
+ }
+
+ override fun onPlaybackEnded() {
+ // Don't disable bit-perfect when a track ends, keep it active for the next track
+ }
+}
diff --git a/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt
index 20d241eef..42914c6c1 100644
--- a/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt
+++ b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt
@@ -15,6 +15,7 @@ import com.simplecityapps.mediaprovider.repository.genres.GenreRepository
import com.simplecityapps.mediaprovider.repository.playlists.PlaylistRepository
import com.simplecityapps.mediaprovider.repository.songs.SongRepository
import com.simplecityapps.playback.AudioEffectSessionManager
+import com.simplecityapps.playback.BitPerfectManager
import com.simplecityapps.playback.NoiseManager
import com.simplecityapps.playback.PlaybackManager
import com.simplecityapps.playback.PlaybackNotificationManager
@@ -225,6 +226,14 @@ class PlaybackModule {
playbackWatcher: PlaybackWatcher
): NoiseManager = NoiseManager(context, playbackManager, playbackWatcher)
+ @Singleton
+ @Provides
+ fun provideBitPerfectManager(
+ @ApplicationContext context: Context,
+ playbackPreferenceManager: PlaybackPreferenceManager,
+ playbackWatcher: PlaybackWatcher
+ ): BitPerfectManager = BitPerfectManager(context, playbackPreferenceManager, playbackWatcher)
+
@Singleton
@Provides
fun providePlaybackNotificationManager(
diff --git a/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt b/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt
index 1ce7aaefc..175a3ed44 100644
--- a/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt
+++ b/android/playback/src/main/java/com/simplecityapps/playback/persistence/PlaybackPreferenceManager.kt
@@ -96,6 +96,14 @@ class PlaybackPreferenceManager(
return sharedPreferences.get("equalizer_enabled", false)
}
+ var bitPerfectEnabled: Boolean
+ set(value) {
+ sharedPreferences.put("bit_perfect_enabled", value)
+ }
+ get() {
+ return sharedPreferences.get("bit_perfect_enabled", false)
+ }
+
var replayGainMode: ReplayGainMode
set(value) {
sharedPreferences.put("replaygain_mode", value.ordinal)