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)