Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/src/main/java/github/paroj/dsub2000/service/AudioPlayer.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package github.paroj.dsub2000.service;

import android.content.Context;
import android.media.AudioDeviceInfo;
import android.media.PlaybackParams;

import androidx.annotation.RequiresApi;
Expand Down Expand Up @@ -75,6 +76,17 @@ public interface AudioPlayer {
*/
void setNextMediaPlayer(AudioPlayer next);

/**
* Route playback to the given output device (e.g. a connected USB DAC). Must
* be called before {@link #prepareAsync()} to take effect on the next track.
* Default no-op for backends that don't support routing.
*
* @return true if the call was applied; false if unsupported on this backend
* or on this Android version.
*/
@RequiresApi(28)
default boolean setPreferredDevice(AudioDeviceInfo device) { return false; }

interface OnPreparedListener {
void onPrepared(AudioPlayer player);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.media.MediaPlayer;
// AudioPlayer abstraction: legacy MediaPlayer is used for local files via
Expand Down Expand Up @@ -2042,6 +2043,24 @@ private synchronized void doPlay(final DownloadFile downloadFile, final int posi
}

mediaPlayer.setDataSource(dataSource);

// Optional USB DAC routing (issue #141). Apply before prepareAsync so
// the player picks up the device on the upcoming prepare. Hot-plug
// mid-track is not handled here; the next track will pick up the new
// device. TODO: register AudioDeviceCallback to re-route mid-track.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
AudioDeviceInfo usbDevice = UsbDacHelper.findUsbAudioDevice(this);
if (usbDevice != null) {
mediaPlayer.setPreferredDevice(usbDevice);
if (!downloadFile.isStream()) {
Integer rate = UsbDacHelper.readSampleRate(dataSource);
if (rate != null) {
Log.i(TAG, "USB DAC routing enabled; source sample rate " + rate + " Hz");
}
}
}
}

setPlayerState(PREPARING);

mediaPlayer.setOnBufferingUpdateListener(new AudioPlayer.OnBufferingUpdateListener() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
package github.paroj.dsub2000.service;

import android.content.Context;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.media.PlaybackParams;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import androidx.annotation.OptIn;
import androidx.annotation.RequiresApi;
Expand All @@ -33,6 +35,8 @@
@OptIn(markerClass = UnstableApi.class)
public class ExoPlayerAudio implements AudioPlayer {

private static final String TAG = ExoPlayerAudio.class.getSimpleName();

private final ExoPlayer player;
private final Handler mainHandler;

Expand Down Expand Up @@ -255,4 +259,15 @@ public void setPlaybackParams(PlaybackParams params) {
public void setNextMediaPlayer(AudioPlayer next) {
// Live HTTP streams have no end of file; gapless does not apply. No-op.
}

@Override @RequiresApi(28)
public boolean setPreferredDevice(AudioDeviceInfo device) {
try {
onPlayerThread(() -> player.setPreferredAudioDevice(device));
return true;
} catch (Throwable t) {
Log.w(TAG, "setPreferredAudioDevice failed", t);
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
package github.paroj.dsub2000.service;

import android.content.Context;
import android.media.AudioDeviceInfo;
import android.media.MediaPlayer;
import android.media.PlaybackParams;
import android.util.Log;

import androidx.annotation.RequiresApi;

Expand All @@ -20,6 +22,8 @@
*/
public class MediaPlayerAudio implements AudioPlayer {

private static final String TAG = MediaPlayerAudio.class.getSimpleName();

private final MediaPlayer mp = new MediaPlayer();

/**
Expand Down Expand Up @@ -91,6 +95,16 @@ public void setOnBufferingUpdateListener(final OnBufferingUpdateListener listene
@Override @RequiresApi(23)
public void setPlaybackParams(PlaybackParams params) { mp.setPlaybackParams(params); }

@Override @RequiresApi(28)
public boolean setPreferredDevice(AudioDeviceInfo device) {
try {
return mp.setPreferredDevice(device);
} catch (Throwable t) {
Log.w(TAG, "setPreferredDevice failed", t);
return false;
}
}

@Override
public void setNextMediaPlayer(AudioPlayer next) {
if (next == null) {
Expand Down
99 changes: 99 additions & 0 deletions app/src/main/java/github/paroj/dsub2000/service/UsbDacHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
This file is part of DSub2000.
Released under GPLv3, see project LICENSE.txt.
*/
package github.paroj.dsub2000.service;

import android.content.Context;
import android.content.SharedPreferences;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.os.Build;
import android.util.Log;

import androidx.annotation.RequiresApi;

import github.paroj.dsub2000.util.Constants;
import github.paroj.dsub2000.util.Util;

/**
* Helpers for the optional "Route to USB DAC" preference (see issue #141).
* Locates a connected USB audio output device so callers can hand it to
* {@link AudioPlayer#setPreferredDevice(AudioDeviceInfo)} before {@code prepare},
* and reads source sample rates for diagnostics so users can confirm what the
* DAC is actually receiving.
*/
public final class UsbDacHelper {

private static final String TAG = UsbDacHelper.class.getSimpleName();

private UsbDacHelper() {}

/**
* @return the first connected USB audio output device, or {@code null} if the
* preference is disabled, the OS is older than Android 9, or no USB
* DAC is plugged in.
*/
public static AudioDeviceInfo findUsbAudioDevice(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null;
}
SharedPreferences prefs = Util.getPreferences(context);
if (!prefs.getBoolean(Constants.PREFERENCES_KEY_USB_DAC_EXCLUSIVE_MODE, false)) {
return null;
}
return findUsbAudioDeviceInternal(context);
}

@RequiresApi(Build.VERSION_CODES.P)
private static AudioDeviceInfo findUsbAudioDeviceInternal(Context context) {
AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
if (am == null) {
return null;
}
AudioDeviceInfo[] devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS);
for (AudioDeviceInfo info : devices) {
int type = info.getType();
if (type == AudioDeviceInfo.TYPE_USB_DEVICE
|| type == AudioDeviceInfo.TYPE_USB_HEADSET
|| type == AudioDeviceInfo.TYPE_USB_ACCESSORY) {
Log.i(TAG, "Routing playback to USB audio device type=" + type
+ " name=" + info.getProductName());
return info;
}
}
return null;
}

/**
* Read the source sample rate of a local media file. Useful for confirming
* via {@code adb logcat} that a high-res FLAC actually reaches the player
* at its native rate (Android may still resample internally).
*
* @return sample rate in Hz, or {@code null} on failure / non-local source.
*/
public static Integer readSampleRate(String localPath) {
if (localPath == null) {
return null;
}
MediaExtractor extractor = new MediaExtractor();
try {
extractor.setDataSource(localPath);
for (int i = 0; i < extractor.getTrackCount(); i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
if (mime != null && mime.startsWith("audio/")
&& format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
return format.getInteger(MediaFormat.KEY_SAMPLE_RATE);
}
}
} catch (Throwable t) {
Log.w(TAG, "Could not read sample rate from " + localPath, t);
} finally {
extractor.release();
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ public final class Constants {
public static final String PREFERENCES_KEY_PERSISTENT_NOTIFICATION = "persistentNotification";
public static final String PREFERENCES_KEY_MEDIA_STYLE_NOTIFICATION = "mediaStyleNotification";
public static final String PREFERENCES_KEY_GAPLESS_PLAYBACK = "gaplessPlayback";
public static final String PREFERENCES_KEY_USB_DAC_EXCLUSIVE_MODE = "usbDacExclusiveMode";
public static final String PREFERENCES_KEY_REMOVE_PLAYED = "removePlayed";
public static final String PREFERENCES_KEY_KEEP_PLAYED_CNT = "keepPlayedCount";
public static final String PREFERENCES_KEY_SHUFFLE_MODE = "shuffleMode2";
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,8 @@
<string name="settings.persistent_summary">Show the notification even after pausing. Press the stop button to clear it away.</string>
<string name="settings.gapless_playback">Gapless Playback</string>
<string name="settings.gapless_playback_summary">If you are seeing strange bugs during playback, turning this off may help.</string>
<string name="settings.usb_dac_exclusive_title">Route to USB DAC</string>
<string name="settings.usb_dac_exclusive_summary">Send playback to a connected USB DAC when present. Applies on the next track. Android 9+.</string>
<string name="settings.chat_refresh">Chat Refresh Rate (Secs)</string>
<string name="settings.chat_enabled">Chat Enabled</string>
<string name="settings.chat_enabled_summary">Whether or not to display the chat listing in the pull out drawer</string>
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/xml/settings_playback.xml
Original file line number Diff line number Diff line change
Expand Up @@ -170,5 +170,11 @@
android:summary="@string/settings.start_on_headphones_summary"
android:key="startOnHeadphones"
android:defaultValue="false"/>

<CheckBoxPreference
android:title="@string/settings.usb_dac_exclusive_title"
android:summary="@string/settings.usb_dac_exclusive_summary"
android:key="usbDacExclusiveMode"
android:defaultValue="false"/>
</PreferenceCategory>
</PreferenceScreen>
Loading