diff --git a/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt b/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt index 103fce2..ff0aec8 100644 --- a/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt +++ b/android/android/app/src/main/java/com/ariacockpit/AudioFocusModule.kt @@ -5,7 +5,9 @@ import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.os.Build +import android.os.SystemClock import android.util.Log +import android.view.KeyEvent import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -183,6 +185,50 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase promise.resolve(true) } + /** Zuverlaessiger Spotify-Resume: einen echten MEDIA_PLAY-Tastendruck an die + * aktive MediaSession schicken — exakt das Signal der Play-Taste am + * Bluetooth-Kopfhoerer. Anders als nudgeMediaResume (Focus-Stack-Trick, + * auf manchen OEMs/Spotify-Versionen unzuverlaessig) spricht das Spotifys + * MediaSession DIREKT an und startet die Wiedergabe deterministisch wieder. + * + * Wir senden bewusst KEYCODE_MEDIA_PLAY (nicht PLAY_PAUSE) — das kann nur + * starten, nie pausieren. Aufrufer muss also selbst gaten (nur senden wenn + * vor dem Gespraech wirklich Musik lief, siehe isMusicActive()). + */ + @ReactMethod + fun dispatchMediaPlay(promise: Promise) { + val am = audioManager() + if (am == null) { + promise.resolve(false) + return + } + try { + val now = SystemClock.uptimeMillis() + val down = KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0) + val up = KeyEvent(now, now, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0) + am.dispatchMediaKeyEvent(down) + am.dispatchMediaKeyEvent(up) + Log.i(TAG, "dispatchMediaPlay: KEYCODE_MEDIA_PLAY an aktive MediaSession gesendet") + promise.resolve(true) + } catch (e: Exception) { + Log.w(TAG, "dispatchMediaPlay failed: ${e.message}") + promise.resolve(false) + } + } + + /** Ob gerade Musik/Media aktiv abgespielt wird (AudioManager.isMusicActive). + * Der Aufrufer merkt sich das VOR dem Focus-Grab, um am Dialog-Ende zu + * entscheiden ob ein dispatchMediaPlay-Resume ueberhaupt gewuenscht ist. */ + @ReactMethod + fun isMusicActive(promise: Promise) { + val am = audioManager() + if (am == null) { + promise.resolve(false) + return + } + promise.resolve(am.isMusicActive) + } + /** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube * resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus * nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA diff --git a/android/src/services/audio.ts b/android/src/services/audio.ts index 927a911..7199895 100644 --- a/android/src/services/audio.ts +++ b/android/src/services/audio.ts @@ -44,6 +44,11 @@ const { AudioFocus, PcmStreamPlayer, PcmStreamRecorder } = NativeModules as { release: () => Promise; kickReleaseMedia: () => Promise; getMode?: () => Promise; + // Zuverlaessiger Spotify-Resume via echtem MEDIA_PLAY-KeyEvent (statt + // Focus-Stack-Nudge). isMusicActive() zum Gaten: nur resumen wenn vor + // dem Gespraech wirklich Musik lief. + dispatchMediaPlay?: () => Promise; + isMusicActive?: () => Promise; }; PcmStreamPlayer?: { start: (sampleRate: number, channels: number, prerollSeconds: number) => Promise; @@ -275,6 +280,13 @@ class AudioService { // damit Spotify nicht in Render-Pausen oder zwischen Antworten zurueckkehrt. private _conversationFocusActive: boolean = false; + // Lief unmittelbar VOR dem Focus-Grab (Wake-Word/Aufnahme) Musik? Wird beim + // Betreten des Dialogs gemerkt (latch: nur auf true), damit wir am Dialog-Ende + // NUR dann Spotify per MEDIA_PLAY-KeyEvent zuverlaessig resumen, wenn vorher + // wirklich etwas lief. Verhindert, dass wir bei Stille versehentlich Musik + // starten. Wird nach dem Resume-Dispatch wieder auf false gesetzt. + private _mediaWasActiveAtAcquire: boolean = false; + // VAD State private vadEnabled: boolean = false; private lastSpeechTime: number = 0; @@ -450,15 +462,29 @@ class AudioService { import('./logger').then(m => m.reportAppDebug('audio.focus', 'AudioFocus.release() now')).catch(()=>{}); AudioFocus?.release().catch(() => {}); - // Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack - // mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei - // manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss. - // 50ms Delay damit das Abandon erst durch ist. - setTimeout(() => { + // Spotify-Resume: NUR wenn vor dem Gespraech wirklich Musik lief. Dann + // einen echten MEDIA_PLAY-KeyEvent an die aktive MediaSession schicken + // (wie die Play-Taste am Kopfhoerer) — das resumt Spotify zuverlaessig, + // im Gegensatz zum flakigen Focus-Stack-Nudge, der auf manchen Geraeten + // (OnePlus) nach Auto-Loss nicht griff. 120ms Delay, damit das Abandon + // sicher durch ist, bevor der Play-Key kommt. + const shouldResume = this._mediaWasActiveAtAcquire; + this._mediaWasActiveAtAcquire = false; + if (shouldResume) { + setTimeout(() => { + import('./logger').then(m => m.reportAppDebug('audio.focus', + 'dispatchMediaPlay() now (Musik lief vor Dialog → Resume)')).catch(()=>{}); + if (AudioFocus?.dispatchMediaPlay) { + AudioFocus.dispatchMediaPlay().catch(() => {}); + } else { + // Fallback fuer alte Native-Builds ohne dispatchMediaPlay + AudioFocus?.nudgeMediaResume().catch(() => {}); + } + }, 120); + } else { import('./logger').then(m => m.reportAppDebug('audio.focus', - 'nudgeMediaResume() now (50ms after release)')).catch(()=>{}); - AudioFocus?.nudgeMediaResume().catch(() => {}); - }, 50); + 'kein Resume (vor Dialog lief keine Musik)')).catch(()=>{}); + } }, this.FOCUS_RELEASE_DELAY_MS); } @@ -469,6 +495,20 @@ class AudioService { } } + /** Merkt sich (latch: nur auf true), ob GERADE Musik laeuft — VOR einem + * Focus-Grab aufrufen. Am Dialog-Ende entscheidet die Flag, ob wir Spotify + * aktiv per MEDIA_PLAY resumen. Awaitet bewusst isMusicActive bevor der + * Focus-Request die Wiedergabe pausiert (sonst laese man schon 'false'). */ + private async _captureMediaActive(): Promise { + try { + const active = await AudioFocus?.isMusicActive?.(); + if (active) { + this._mediaWasActiveAtAcquire = true; + console.log('[Audio] Musik lief vor Focus-Grab → Resume am Dialog-Ende gemerkt'); + } + } catch {} + } + /** Conversation-Mode beginnt → AudioFocus dauerhaft halten (Spotify bleibt * pausiert). Idempotent: mehrfaches Aufrufen ist sicher. */ acquireConversationFocus(): void { @@ -476,7 +516,11 @@ class AudioService { this._conversationFocusActive = true; this._cancelDeferredFocusRelease(); console.log('[Audio] Conversation-Focus aktiv (Spotify bleibt gepaust)'); - AudioFocus?.requestDuck().catch(() => {}); + // Erst pruefen ob Musik laeuft, DANN ducken (requestDuck wuerde sie sonst + // schon pausieren bevor wir es messen koennen). + this._captureMediaActive().finally(() => { + AudioFocus?.requestDuck().catch(() => {}); + }); } /** Conversation-Mode endet → Focus darf wieder freigegeben werden @@ -493,6 +537,8 @@ class AudioService { haltAllPlayback(reason: string = ''): void { console.log('[Audio] haltAllPlayback: %s', reason || '(no reason)'); this._conversationFocusActive = false; + // Barge-In → User spricht gleich weiter, Spotify NICHT resumen. + this._mediaWasActiveAtAcquire = false; this.stopPlayback(); } @@ -503,6 +549,8 @@ class AudioService { pauseForCall(reason: string = ''): void { console.log('[Audio] pauseForCall: %s', reason || '(no reason)'); this._conversationFocusActive = false; + // Anruf → Spotify bleibt aus, kein Auto-Resume beim spaeteren Release. + this._mediaWasActiveAtAcquire = false; this._pausedForCall = true; // Queue + isPlaying ruecksetzen — sonst klemmt der naechste Play-Button // (playAudio sieht isPlaying=true und ruft _playNext nicht mehr auf). @@ -796,6 +844,8 @@ class AudioService { this.setState('recording'); // Andere Apps waehrend der Aufnahme pausieren (Musik, Videos etc.) + // Vorher merken ob Musik lief, damit wir sie danach resumen koennen. + await this._captureMediaActive(); this._cancelDeferredFocusRelease(); AudioFocus?.requestExclusive().catch(() => {}); @@ -1043,6 +1093,8 @@ class AudioService { } // AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad. + // Vorher merken ob Musik lief (fuer Resume am Dialog-Ende). + await this._captureMediaActive(); this._cancelDeferredFocusRelease(); AudioFocus?.requestExclusive().catch(() => {});