From ec4b0fba98059ff2b722bd54303b750feafd3df7 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 31 Aug 2026 21:07:51 -0700 Subject: [PATCH] RG-T89 Audio fix --- __mocks__/expo-audio.ts | 3 ++ app.config.ts | 5 ++ .../app/__tests__/audio-stream-store.test.ts | 38 +++++++++++++ src/stores/app/audio-stream-store.ts | 53 +++++++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/__mocks__/expo-audio.ts b/__mocks__/expo-audio.ts index 2b3a3750..ef0755e3 100644 --- a/__mocks__/expo-audio.ts +++ b/__mocks__/expo-audio.ts @@ -23,6 +23,9 @@ const createMockAudioPlayer = () => ({ seekTo: jest.fn().mockResolvedValue(undefined), remove: jest.fn(), addListener: jest.fn(() => ({ remove: jest.fn() })), + setActiveForLockScreen: jest.fn(), + updateLockScreenMetadata: jest.fn(), + clearLockScreenControls: jest.fn(), }); export const createAudioPlayer = jest.fn(createMockAudioPlayer); diff --git a/app.config.ts b/app.config.ts index 86093cad..a2ce6e37 100644 --- a/app.config.ts +++ b/app.config.ts @@ -109,6 +109,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'android.permission.FOREGROUND_SERVICE', 'android.permission.FOREGROUND_SERVICE_MICROPHONE', 'android.permission.FOREGROUND_SERVICE_PHONE_CALL', + // Department audio (scanner) streams keep playing while backgrounded through + // expo-audio's AudioControlsService. The expo-audio config plugin adds this permission + // too, but it is declared here so the FGS types the app actually uses are all visible + // in one place next to the Play declarations. + 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK', 'android.permission.READ_PHONE_STATE', 'android.permission.READ_PHONE_NUMBERS', 'android.permission.MANAGE_OWN_CALLS', diff --git a/src/stores/app/__tests__/audio-stream-store.test.ts b/src/stores/app/__tests__/audio-stream-store.test.ts index 2b91b42d..2774175d 100644 --- a/src/stores/app/__tests__/audio-stream-store.test.ts +++ b/src/stores/app/__tests__/audio-stream-store.test.ts @@ -16,6 +16,7 @@ jest.mock('@/lib/logging', () => ({ logger: { debug: jest.fn(), info: jest.fn(), + warn: jest.fn(), error: jest.fn(), }, })); @@ -56,6 +57,8 @@ describe('AudioStreamStore', () => { remove: jest.fn(), seekTo: jest.fn(() => Promise.resolve()), addListener: jest.fn(() => ({ remove: jest.fn() })), + setActiveForLockScreen: jest.fn(), + clearLockScreenControls: jest.fn(), } as any; beforeEach(() => { @@ -79,6 +82,8 @@ describe('AudioStreamStore', () => { mockSoundObject.remove.mockImplementation(() => undefined); mockSoundObject.seekTo.mockImplementation(() => Promise.resolve()); mockSoundObject.addListener.mockImplementation(() => ({ remove: jest.fn() })); + mockSoundObject.setActiveForLockScreen.mockImplementation(() => undefined); + mockSoundObject.clearLockScreenControls.mockImplementation(() => undefined); // Mock expo-audio methods mockSetAudioModeAsync.mockResolvedValue(undefined); @@ -276,6 +281,38 @@ describe('AudioStreamStore', () => { }); }); + it('should register the player with the OS media session so it can be paused outside the app', async () => { + await useAudioStreamStore.getState().playStream(mockStream); + + expect(mockSoundObject.setActiveForLockScreen).toHaveBeenCalledWith( + true, + { title: mockStream.Name }, + { + isLiveStream: true, + showSeekForward: false, + showSeekBackward: false, + } + ); + }); + + it('should keep playing when media session registration fails', async () => { + const lockScreenError = new Error('Service binding failed'); + mockSoundObject.setActiveForLockScreen.mockImplementationOnce(() => { + throw lockScreenError; + }); + + await useAudioStreamStore.getState().playStream(mockStream); + + const state = useAudioStreamStore.getState(); + expect(state.soundObject).toEqual(mockSoundObject); + expect(state.isPlaying).toBe(true); + expect(mockSoundObject.play).toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Failed to activate audio stream media controls', + context: { error: lockScreenError, streamName: mockStream.Name }, + }); + }); + it('should stop current stream before playing new one', async () => { // Set up existing stream useAudioStreamStore.setState({ @@ -406,6 +443,7 @@ describe('AudioStreamStore', () => { expect(state.isBuffering).toBe(false); expect(mockSoundObject.pause).toHaveBeenCalled(); + expect(mockSoundObject.clearLockScreenControls).toHaveBeenCalled(); expect(mockSoundObject.remove).toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith({ diff --git a/src/stores/app/audio-stream-store.ts b/src/stores/app/audio-stream-store.ts index 9045f863..69769ecb 100644 --- a/src/stores/app/audio-stream-store.ts +++ b/src/stores/app/audio-stream-store.ts @@ -166,6 +166,50 @@ const logStreamDiagnostics = (source: ResolvedStreamSource, stream: DepartmentAu }); }; +/** + * Publishes the player to the OS media session: an Android media-style notification with a + * play/pause control (plus the lock screen), and Now Playing / Control Center on iOS. + * + * On Android this is what starts expo-audio's `AudioControlsService`, the `mediaPlayback` + * foreground service declared by the expo-audio config plugin. Without it a backgrounded + * stream keeps playing with no user-visible transport control — which is what Google Play + * rejected — and the OS kills the playback after roughly three minutes anyway. + * + * `isLiveStream` drops the scrub bar and seek buttons: a scanner feed has no duration and + * nothing buffered to seek into. + */ +const activateMediaControls = (player: AudioPlayer, stream: DepartmentAudioResultStreamData) => { + try { + player.setActiveForLockScreen( + true, + { title: stream.Name ?? '' }, + { + isLiveStream: true, + showSeekForward: false, + showSeekBackward: false, + } + ); + } catch (error) { + // Playback still works without the notification, so never fail the stream over this. + logger.warn({ + message: 'Failed to activate audio stream media controls', + context: { error, streamName: stream.Name }, + }); + } +}; + +/** Tears down the media notification / Now Playing entry before the player is released. */ +const releaseMediaControls = (player: AudioPlayer) => { + try { + player.clearLockScreenControls(); + } catch (error) { + logger.warn({ + message: 'Failed to clear audio stream media controls', + context: { error }, + }); + } +}; + let latestPlayRequestId = 0; export const useAudioStreamStore = create((set, get) => ({ @@ -272,6 +316,10 @@ export const useAudioStreamStore = create((set, get) => ({ set({ soundObject: sound, currentStream: stream }); + // Register with the OS media session before playback starts so the notification and + // its pause control exist from the first frame of audio. + activateMediaControls(sound, stream); + sound.addListener('playbackStatusUpdate', (status: AudioStatus) => { if (get().soundObject !== sound) { return; @@ -283,6 +331,7 @@ export const useAudioStreamStore = create((set, get) => ({ context: { error: status.error, streamName: stream.Name, streamUrl: redactStreamUrl(streamUrl) }, }); logStreamDiagnostics(source, stream); + releaseMediaControls(sound); sound.remove(); set({ soundObject: null, @@ -359,6 +408,7 @@ export const useAudioStreamStore = create((set, get) => ({ const { soundObject } = get(); if (soundObject) { try { + releaseMediaControls(soundObject); soundObject.remove(); } catch { // The player may already have been released by an error event. @@ -383,6 +433,9 @@ export const useAudioStreamStore = create((set, get) => ({ try { soundObject.pause(); } finally { + // Drop the media notification and stop the mediaPlayback foreground service before + // the player goes away, otherwise a dead notification lingers in the shade. + releaseMediaControls(soundObject); soundObject.remove(); }