Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
08acf6f
feat(usb): add :usbaudio native module scaffold with vendored libusb …
claude Jul 2, 2026
92e5327
feat(usb): USB DAC detection, permission flow and remembered devices
claude Jul 2, 2026
e1d95ff
feat(usb): UAC1/UAC2 descriptor parsing and bit-perfect format negoti…
claude Jul 2, 2026
2683047
feat(usb): native isochronous UAC driver and UsbAudioSession wrapper
claude Jul 2, 2026
86bb61d
feat(usb): UsbAudioSink — custom Media3 sink feeding the USB driver
claude Jul 2, 2026
ccfd4da
feat(usb): exclusive-mode controller and engine/service integration
claude Jul 3, 2026
12a44f6
feat(usb): USB Audio settings screen and Now Playing output badge
claude Jul 3, 2026
27df4ff
docs(usb): exclusive-mode architecture, policies and hardware test ch…
claude Jul 3, 2026
e4b429c
fix(usb): probe DAC capabilities after claiming interfaces; support c…
claude Jul 3, 2026
5dc7f87
fix(usb): clickless skips/seeks/pauses via synthetic edge ramps; gapl…
claude Jul 4, 2026
f631b46
ci: dispatchable workflow publishing signed beta APKs as a GitHub Rel…
claude Jul 4, 2026
1e5b0ec
ci: allow triggering the beta release workflow by tag push
claude Jul 4, 2026
e5913a3
ci: cut usb beta release v0.7.5-usb-beta.1 via branch-push trigger
claude Jul 4, 2026
d4b1e48
feat(usb): software volume safety layer for DACs without hardware volume
claude Jul 8, 2026
30aac34
ci: cut usb beta release v0.7.5-usb-beta.2
claude Jul 8, 2026
39fd7a0
fix(usb): advertise device-volume commands so volume keys reach the DAC
claude Jul 9, 2026
021ec3d
ci: cut usb beta release v0.7.5-usb-beta.3
claude Jul 9, 2026
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
1 change: 1 addition & 0 deletions .github/release-tag.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v0.7.5-usb-beta.3
136 changes: 136 additions & 0 deletions .github/workflows/usb-beta-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: USB Beta Release

# Builds signed release APKs and publishes them as a GitHub Release.
# Two triggers:
# - manual dispatch (tag comes from the input), or
# - a push that changes .github/release-tag.txt (tag comes from that file) — this
# lets a release be cut from an environment that can push the branch but cannot
# call the workflow-dispatch API or push tags.
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (also used in the APK file names)'
required: true
default: 'v0.7.5-usb-beta.1'
push:
branches:
- claude/new-session-lpe3cm
paths:
- '.github/release-tag.txt'

permissions:
contents: write

concurrency:
group: usb-beta-release
cancel-in-progress: false

jobs:
release:
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Resolve release tag
id: tag
env:
INPUT_TAG: ${{ inputs.tag }}
run: |
if [ -n "${INPUT_TAG}" ]; then
TAG="${INPUT_TAG}"
else
TAG="$(tr -d '[:space:]' < .github/release-tag.txt)"
fi
if [ -z "${TAG}" ]; then
echo "No release tag resolved" >&2
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6

# Same cache key as nightly-apk.yml: beta releases and nightlies share one
# signature, so update-installs work across them.
- name: Cache nightly keystore
id: cache-keystore
uses: actions/cache@v5
with:
path: vz-pixelplay.jks
key: ${{ runner.os }}-nightly-keystore-vz-pixelplay

- name: Generate nightly keystore if not cached
if: steps.cache-keystore.outputs.cache-hit != 'true'
run: |
keytool -genkey -v -keystore vz-pixelplay.jks -alias pixelplay-nightly-key -keyalg RSA -keysize 4096 -validity 10000 \
-storepass 994273 -keypass 994273 \
-dname "CN=PixelPlay Nightly, OU=Dev, O=PixelPlay, L=World, S=World, C=US"

- name: Create keystore.properties
run: |
echo "storePassword=994273" > keystore.properties
echo "keyAlias=pixelplay-nightly-key" >> keystore.properties
echo "keyPassword=994273" >> keystore.properties

- name: Build release APKs
run: gradle :app:assembleRelease -Ppixelplay.enableAbiSplits=true

- name: Verify split APKs
run: |
BUILD_TOOLS_VERSION="$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -n 1)"
for apk in \
app/build/outputs/apk/release/app-arm64-v8a-release.apk \
app/build/outputs/apk/release/app-armeabi-v7a-release.apk
do
"$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/aapt2" dump badging "$apk" >/dev/null
"$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION/apksigner" verify --verbose "$apk"
done

- name: Stage release assets
env:
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
mkdir -p release-assets
cp app/build/outputs/apk/release/app-arm64-v8a-release.apk \
"release-assets/PixelPlay-${RELEASE_TAG}-arm64-v8a.apk"
cp app/build/outputs/apk/release/app-armeabi-v7a-release.apk \
"release-assets/PixelPlay-${RELEASE_TAG}-armeabi-v7a.apk"

- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: 'USB Exclusive Mode (bit-perfect) — ${{ steps.tag.outputs.tag }}'
prerelease: true
files: release-assets/*.apk
body: |
Beta build of **USB exclusive mode**: PixelPlay claims a USB DAC directly
(libusb, no root) and streams at the track's native sample rate and bit
depth — bit-perfect, bypassing the Android mixer. Validated on a FiiO
BTR17 up to 768 kHz.

**Install:** download the `arm64-v8a` APK (fits virtually all phones;
`armeabi-v7a` is only for very old 32-bit devices), open it on the phone
and allow installing from unknown sources.

**Signature note:** this build is signed with the CI test key (same as the
nightly builds). It cannot update-install over a copy of PixelPlay signed
with a different key — uninstall that one first, or install alongside a
`.debug` test build (different application id, no conflict).

**Usage:** Settings → Playback → USB Audio → enable *USB exclusive mode*,
plug in the DAC, grant the USB permission. The Now Playing screen shows
`USB • <bit>/<rate>` while bit-perfect output is active, and the DAC's own
display follows the track's sample rate.

Full feature documentation: `docs/usb-exclusive-mode.md` in the repo.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ app/release/
wear/build/
wear/release/
shared/build/
usbaudio/build/
baselineprofile/build/

misc.xml
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ dependencies {

// Projects
implementation(project(":shared"))
implementation(project(":usbaudio"))

// Testing (Unit)
testImplementation(libs.junit.jupiter.api)
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />

<!-- USB DAC exclusive mode (optional: the app works fully without USB host support). -->
<uses-feature
android:name="android.hardware.usb.host"
android:required="false" />

<application
android:name=".PixelPlayApplication"
android:allowBackup="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.theveloper.pixelplay.data.model.Playlist
import com.theveloper.pixelplay.data.model.SortOption
import com.theveloper.pixelplay.data.model.StorageFilter
import com.theveloper.pixelplay.data.model.TransitionSettings
import com.theveloper.pixelplay.data.usb.UsbRememberedDevice
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -140,6 +141,10 @@ class UserPreferencesRepository @Inject constructor(
val KEEP_PLAYING_IN_BACKGROUND = booleanPreferencesKey("keep_playing_in_background")
val IS_CROSSFADE_ENABLED = booleanPreferencesKey("is_crossfade_enabled")
val HI_FI_MODE_ENABLED = booleanPreferencesKey("hi_fi_mode_enabled")
val USB_EXCLUSIVE_MODE_ENABLED = booleanPreferencesKey("usb_exclusive_mode_enabled")
val USB_REMEMBERED_DEVICES = stringPreferencesKey("usb_remembered_devices_json")
val USB_EXCLUSIVE_MAX_VOLUME_ACK = booleanPreferencesKey("usb_exclusive_max_volume_ack")
val USB_FIXED_VOLUME_OUTPUT = booleanPreferencesKey("usb_fixed_volume_output")
val CROSSFADE_DURATION = intPreferencesKey("crossfade_duration")
val CUSTOM_GENRES = stringSetPreferencesKey("custom_genres")
val CUSTOM_GENRE_ICONS = stringPreferencesKey("custom_genre_icons")
Expand Down Expand Up @@ -349,6 +354,40 @@ class UserPreferencesRepository @Inject constructor(
dataStore.edit { it[PreferencesKeys.HI_FI_MODE_ENABLED] = enabled }
}

val usbExclusiveModeEnabledFlow: Flow<Boolean> =
pref { it[PreferencesKeys.USB_EXCLUSIVE_MODE_ENABLED] ?: false }

suspend fun setUsbExclusiveModeEnabled(enabled: Boolean) {
dataStore.edit { it[PreferencesKeys.USB_EXCLUSIVE_MODE_ENABLED] = enabled }
}

/** Remembered USB DACs keyed by [com.theveloper.pixelplay.data.usb.UsbDeviceInfo.key]. */
val usbRememberedDevicesFlow: Flow<Map<String, UsbRememberedDevice>> =
pref { decodeJsonPref(it, PreferencesKeys.USB_REMEMBERED_DEVICES, emptyMap()) }

suspend fun rememberUsbDevice(key: String, device: UsbRememberedDevice) {
editJsonMap<UsbRememberedDevice>(PreferencesKeys.USB_REMEMBERED_DEVICES) { put(key, device) }
}

suspend fun forgetUsbDevice(key: String) {
editJsonMap<UsbRememberedDevice>(PreferencesKeys.USB_REMEMBERED_DEVICES) { remove(key) }
}

val usbExclusiveMaxVolumeAckFlow: Flow<Boolean> =
pref { it[PreferencesKeys.USB_EXCLUSIVE_MAX_VOLUME_ACK] ?: false }

suspend fun setUsbExclusiveMaxVolumeAck(acknowledged: Boolean) {
dataStore.edit { it[PreferencesKeys.USB_EXCLUSIVE_MAX_VOLUME_ACK] = acknowledged }
}

/** Purist mode: always full-scale output on volume-less DACs (no software gain stage). */
val usbFixedVolumeOutputFlow: Flow<Boolean> =
pref { it[PreferencesKeys.USB_FIXED_VOLUME_OUTPUT] ?: false }

suspend fun setUsbFixedVolumeOutput(fixed: Boolean) {
dataStore.edit { it[PreferencesKeys.USB_FIXED_VOLUME_OUTPUT] = fixed }
}

val keepPlayingInBackgroundFlow: Flow<Boolean> =
pref { it[PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] ?: true }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import com.theveloper.pixelplay.data.preferences.ThemePreferencesRepository
import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
import com.theveloper.pixelplay.data.repository.MusicRepository
import com.theveloper.pixelplay.data.service.player.DualPlayerEngine
import com.theveloper.pixelplay.data.usb.UsbExclusiveModeController
import com.theveloper.pixelplay.data.usb.UsbExclusiveState
import com.theveloper.pixelplay.data.service.player.TransitionController
import com.theveloper.pixelplay.ui.glancewidget.PlayerActions
import com.theveloper.pixelplay.utils.AlbumArtUtils
Expand All @@ -63,6 +65,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -168,6 +172,8 @@ class MusicService : MediaLibraryService() {
@Inject
lateinit var listeningStatsTracker: ListeningStatsTracker
@Inject
lateinit var usbExclusiveModeController: UsbExclusiveModeController
@Inject
@AppScope
lateinit var appScope: CoroutineScope

Expand All @@ -188,6 +194,9 @@ class MusicService : MediaLibraryService() {
private val controllerLastBrowsedParent = mutableMapOf<String, String>()
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var keepPlayingInBackground = true
// Set when the USB DAC vanished while playing; a re-attach of a remembered device
// with auto-resume then continues playback.
private var usbResumeAfterReattach = false
private var isManualShuffleEnabled = false
private var persistentShuffleEnabled = false
// Holds the previous main-thread UncaughtExceptionHandler so we can restore it in onDestroy.
Expand Down Expand Up @@ -338,13 +347,33 @@ class MusicService : MediaLibraryService() {
replayGainProcessor.onTransitionFinished()
}

/**
* Wraps the player for the media session while USB exclusive mode is engaged so the
* phone's volume keys reach the DAC (hardware feature unit or software gain stage).
* Reuses the existing wrapper when the underlying player hasn't changed, so the
* identity check below keeps working.
*/
private var usbVolumePlayerWrapper: com.theveloper.pixelplay.data.service.player.usb.UsbDeviceVolumePlayer? = null

private fun sessionPlayerFor(player: Player): Player {
if (usbExclusiveModeController.activeSession == null) {
usbVolumePlayerWrapper = null
return player
}
usbVolumePlayerWrapper?.takeIf { it.wrappedPlayer === player }?.let { return it }
return com.theveloper.pixelplay.data.service.player.usb.UsbDeviceVolumePlayer(
player, usbExclusiveModeController
).also { usbVolumePlayerWrapper = it }
}

private fun publishMediaSessionPlayer(player: Player, logMessage: String) {
val session = mediaSession ?: return
val target = sessionPlayerFor(player)
val oldPlayer = session.player
if (oldPlayer !== player) {
if (oldPlayer !== target) {
oldPlayer.removeListener(playerListener)
session.player = player
player.addListener(playerListener)
session.player = target
target.addListener(playerListener)
}

Timber.tag("MusicService").d(logMessage)
Expand Down Expand Up @@ -507,6 +536,42 @@ class MusicService : MediaLibraryService() {
}
}

// USB exclusive mode: attach/detach the bit-perfect sink as the controller's session
// comes and goes. The engine rebuild preserves queue/position (same as Hi-Fi toggle).
serviceScope.launch {
usbExclusiveModeController.state.collect { state ->
val session = when (state) {
is UsbExclusiveState.Ready, is UsbExclusiveState.Active ->
usbExclusiveModeController.activeSession
else -> null
}
engine.setUsbExclusiveMode(
session,
usbExclusiveModeController::onSinkFormatChanged,
usbExclusiveModeController::onSessionDead
)

// Re-attach of a remembered device after an unplug: offer to resume by
// actually resuming, mirroring the headset-reconnect behavior.
if (state is UsbExclusiveState.Ready && usbResumeAfterReattach) {
usbResumeAfterReattach = false
val remembered = userPreferencesRepository.usbRememberedDevicesFlow.first()
if (remembered[state.device.key]?.autoResume == true) {
mediaSession?.player?.play()
}
}
}
}
// DAC unplugged mid-playback: pause immediately; the state collector above swaps the
// engine back to the normal output path without losing the queue.
serviceScope.launch {
usbExclusiveModeController.sessionLost.collect {
val player = mediaSession?.player
usbResumeAfterReattach = player?.playWhenReady == true
player?.pause()
}
}

serviceScope.launch {
userPreferencesRepository.resumeOnHeadsetReconnectFlow.collect { enabled ->
resumeOnHeadsetReconnectEnabled = enabled
Expand All @@ -528,10 +593,16 @@ class MusicService : MediaLibraryService() {
}
}

// ReplayGain preference collectors
// ReplayGain preference collectors. ReplayGain scales the player volume, which the
// bit-perfect USB path must never do — gate it on exclusive mode being inactive.
serviceScope.launch {
userPreferencesRepository.replayGainEnabledFlow.collect { enabled ->
replayGainProcessor.setEnabled(enabled)
combine(
userPreferencesRepository.replayGainEnabledFlow,
usbExclusiveModeController.state
) { enabled, usbState ->
enabled && usbState !is UsbExclusiveState.Ready && usbState !is UsbExclusiveState.Active
}.distinctUntilChanged().collect { effectiveEnabled ->
replayGainProcessor.setEnabled(effectiveEnabled)
// Re-apply to current track when toggled
replayGainProcessor.apply(mediaSession?.player?.currentMediaItem)
}
Expand Down
Loading