feat(nwc): add iOS background audio track picker in settings

This commit is contained in:
ajaysehwal
2026-06-04 10:52:41 -04:00
committed by Evan Kaloudis
parent b53a89a0e3
commit 5cbc86765a
4 changed files with 389 additions and 6 deletions
+4 -2
View File
@@ -233,7 +233,8 @@ RCT_EXPORT_METHOD(setTrack:(NSInteger)index
*/
RCT_EXPORT_METHOD(nextTrack:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
NSInteger next = (self.currentTrackIndex + 1) % self.trackNames.count;
NSInteger count = (NSInteger)self.trackNames.count;
NSInteger next = (self.currentTrackIndex + 1) % count;
[self setTrack:next resolver:resolve rejecter:reject];
}
@@ -242,7 +243,8 @@ RCT_EXPORT_METHOD(nextTrack:(RCTPromiseResolveBlock)resolve
*/
RCT_EXPORT_METHOD(previousTrack:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
NSInteger prev = (self.currentTrackIndex - 1 + self.trackNames.count) % self.trackNames.count;
NSInteger count = (NSInteger)self.trackNames.count;
NSInteger prev = (self.currentTrackIndex - 1 + count) % count;
[self setTrack:prev resolver:resolve rejecter:reject];
}
+2
View File
@@ -2038,6 +2038,8 @@
"views.Settings.NostrWalletConnect.warning.walletBalanceLowerThanBudget": "Your main wallet balance is lower than this connections budget. Please review and update it",
"views.Settings.NostrWalletConnect.warning.budgetLimitReached": "The assigned budget for this connection has reached its limit. Please review and update it if you wish to continue using this connection.",
"views.Settings.NostrWalletConnect.nwcSettings": "NWC Settings",
"views.Settings.NostrWalletConnect.backgroundAudio": "Background Audio",
"views.Settings.NostrWalletConnect.backgroundAudioDescription": "Choose the ambient sound played to keep NWC relay connections alive while Zeus is in the background. Tap the play button to preview a track.",
"views.Settings.NostrWalletConnect.persistentNWCService": "Persistent Mode",
"views.Settings.NostrWalletConnect.nwcIncludeLud16InUrl": "Include Lightning Address",
"views.Settings.NostrWalletConnect.nwcIncludeLud16InUrlDescription": "When enabled, your Lightning address (LUD-16) is added to new and regenerated NWC connection URLs. Some Nostr clients will set this as your Lightning address in your profile. Turn off if you prefer not to share it with the connecting app.",
+87 -2
View File
@@ -1,8 +1,18 @@
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
export interface AudioTrack {
index: number;
name: string;
isSelected: boolean;
}
export interface AudioKeepAliveStatus {
isActive: boolean;
engineRunning: boolean;
playerPlaying: boolean;
isMuted: boolean;
currentTrackIndex: number;
currentTrackName: string;
availableTracks: string[];
/** Seconds the audio session has been alive */
uptimeSeconds: number;
/** Seconds spent in background since last backgrounding */
@@ -36,17 +46,28 @@ export interface AudioStatusUpdatePayload extends AudioKeepAliveStatus {
suspectedSuspension?: boolean;
}
export interface AudioTrackChangedPayload {
trackIndex: number;
trackName: string;
}
export type AudioKeepAliveEventType =
| 'NWCAudioInterrupted'
| 'NWCAudioInterruptionEnded'
| 'NWCAudioRouteChanged'
| 'NWCAudioStatusUpdate'
| 'NWCAudioSuspended';
| 'NWCAudioSuspended'
| 'NWCAudioTrackChanged';
interface NWCAudioKeepAliveModule {
startAudioKeepAlive(): Promise<AudioKeepAliveStatus>;
stopAudioKeepAlive(): Promise<AudioKeepAliveStatus>;
getStatus(): Promise<AudioKeepAliveStatus>;
getAvailableTracks(): Promise<AudioTrack[]>;
setTrack(index: number): Promise<AudioKeepAliveStatus>;
nextTrack(): Promise<AudioKeepAliveStatus>;
previousTrack(): Promise<AudioKeepAliveStatus>;
setMuted(muted: boolean): Promise<AudioKeepAliveStatus>;
addListener(eventType: string): void;
removeListeners(count: number): void;
}
@@ -125,6 +146,61 @@ class IOSAudioKeepAliveUtils {
}
}
async getAvailableTracks(): Promise<AudioTrack[] | null> {
const mod = this.getModule();
if (!mod) return null;
try {
return await mod.getAvailableTracks();
} catch (e) {
console.error('[NWCAudio] getAvailableTracks() failed:', e);
return null;
}
}
async setTrack(index: number): Promise<AudioKeepAliveStatus | null> {
const mod = this.getModule();
if (!mod) return null;
try {
return await mod.setTrack(index);
} catch (e) {
console.error('[NWCAudio] setTrack() failed:', e);
return null;
}
}
async nextTrack(): Promise<AudioKeepAliveStatus | null> {
const mod = this.getModule();
if (!mod) return null;
try {
return await mod.nextTrack();
} catch (e) {
console.error('[NWCAudio] nextTrack() failed:', e);
return null;
}
}
async previousTrack(): Promise<AudioKeepAliveStatus | null> {
const mod = this.getModule();
if (!mod) return null;
try {
return await mod.previousTrack();
} catch (e) {
console.error('[NWCAudio] previousTrack() failed:', e);
return null;
}
}
async setMuted(muted: boolean): Promise<AudioKeepAliveStatus | null> {
const mod = this.getModule();
if (!mod) return null;
try {
return await mod.setMuted(muted);
} catch (e) {
console.error('[NWCAudio] setMuted() failed:', e);
return null;
}
}
onInterrupted(
handler: (payload: AudioInterruptedPayload) => void
): (() => void) | null {
@@ -170,6 +246,15 @@ class IOSAudioKeepAliveUtils {
return () => sub.remove();
}
onTrackChanged(
handler: (payload: AudioTrackChangedPayload) => void
): (() => void) | null {
const emitter = this.getEmitter();
if (!emitter) return null;
const sub = emitter.addListener('NWCAudioTrackChanged', handler);
return () => sub.remove();
}
isAvailable(): boolean {
return this.getModule() !== null;
}
@@ -1,5 +1,12 @@
import React from 'react';
import { View, ScrollView, Text, Platform } from 'react-native';
import {
View,
ScrollView,
Text,
Platform,
TouchableOpacity,
ActivityIndicator
} from 'react-native';
import { Icon } from '@rneui/themed';
import { inject, observer } from 'mobx-react';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -17,6 +24,9 @@ import { themeColor } from '../../../utils/ThemeUtils';
import { localeString } from '../../../utils/LocaleUtils';
import BackendUtils from '../../../utils/BackendUtils';
import { restartNeeded } from '../../../utils/RestartUtils';
import IOSAudioKeepAliveUtils, {
AudioTrack
} from '../../../utils/IOSAudioKeepAliveUtils';
interface NWCSettingsProps {
navigation: NativeStackNavigationProp<any, any>;
@@ -31,6 +41,12 @@ interface NWCSettingsState {
enableCashu: boolean;
persistentNWCServiceEnabled: boolean;
lud16Enabled: boolean;
// iOS background audio track picker
availableTracks: AudioTrack[];
selectedTrackIndex: number;
previewingIndex: number | null;
previewStartedSession: boolean;
previewLoading: boolean;
}
@inject('SettingsStore', 'NostrWalletConnectStore', 'ModalStore')
@@ -46,7 +62,12 @@ export default class NWCSettings extends React.Component<
error: null,
enableCashu: false,
persistentNWCServiceEnabled: false,
lud16Enabled: true
lud16Enabled: true,
availableTracks: [],
selectedTrackIndex: 0,
previewingIndex: null,
previewStartedSession: false,
previewLoading: false
};
}
@@ -61,6 +82,19 @@ export default class NWCSettings extends React.Component<
NostrWalletConnectStore.persistentNWCServiceEnabled,
lud16Enabled: NostrWalletConnectStore.lud16Enabled
});
if (Platform.OS === 'ios' && IOSAudioKeepAliveUtils.isAvailable()) {
this.loadAudioTracks();
}
}
componentWillUnmount() {
if (
this.state.previewStartedSession ||
this.state.previewingIndex !== null
) {
this.stopSettingsPreview(this.state.selectedTrackIndex, true);
}
}
toggleCashuWallet = async () => {
@@ -149,6 +183,261 @@ export default class NWCSettings extends React.Component<
});
};
loadAudioTracks = async () => {
const [tracks, status] = await Promise.all([
IOSAudioKeepAliveUtils.getAvailableTracks(),
IOSAudioKeepAliveUtils.getStatus()
]);
if (tracks) {
this.setState({
availableTracks: tracks,
selectedTrackIndex: status?.currentTrackIndex ?? 0
});
}
};
stopSettingsPreview = async (
persistTrackIndex: number,
skipStateUpdate: boolean = false
) => {
const { NostrWalletConnectStore } = this.props;
const { previewStartedSession } = this.state;
const status = await IOSAudioKeepAliveUtils.getStatus();
const liveNwc = NostrWalletConnectStore.iosAudioKeepAliveActive;
if (previewStartedSession) {
await IOSAudioKeepAliveUtils.stop();
await IOSAudioKeepAliveUtils.setTrack(persistTrackIndex);
} else if (liveNwc && status?.playerPlaying) {
await IOSAudioKeepAliveUtils.setMuted(true);
} else if (status?.isActive && status?.playerPlaying) {
await IOSAudioKeepAliveUtils.stop();
await IOSAudioKeepAliveUtils.setTrack(persistTrackIndex);
}
if (!skipStateUpdate) {
this.setState({
previewingIndex: null,
previewStartedSession: false,
selectedTrackIndex: persistTrackIndex
});
}
};
selectTrack = async (index: number) => {
const { previewingIndex } = this.state;
if (previewingIndex !== null && index !== previewingIndex) {
await this.stopSettingsPreview(index);
return;
}
this.setState({ selectedTrackIndex: index });
const { NostrWalletConnectStore } = this.props;
const sessionActive =
this.state.previewingIndex !== null ||
NostrWalletConnectStore.iosAudioKeepAliveActive;
if (!sessionActive) {
await IOSAudioKeepAliveUtils.setTrack(index);
}
};
togglePreview = async (index: number) => {
const { previewingIndex, previewStartedSession, selectedTrackIndex } =
this.state;
const { NostrWalletConnectStore } = this.props;
// Stop button always silence playback (native loops until stopped).
if (previewingIndex === index) {
await this.stopSettingsPreview(selectedTrackIndex);
return;
}
this.setState({ previewLoading: true, previewingIndex: index });
// Settings preview: stop any previous session before starting the new
// looping track so only one plays at a time.
if (previewStartedSession) {
await IOSAudioKeepAliveUtils.stop();
const status = await IOSAudioKeepAliveUtils.start();
if (!status?.isActive) {
this.setState({
previewingIndex: null,
previewLoading: false,
previewStartedSession: false
});
return;
}
await IOSAudioKeepAliveUtils.setMuted(false);
await IOSAudioKeepAliveUtils.setTrack(index);
this.setState({
previewingIndex: index,
selectedTrackIndex: index,
previewStartedSession: true,
previewLoading: false
});
return;
}
if (NostrWalletConnectStore.iosAudioKeepAliveActive) {
await IOSAudioKeepAliveUtils.setMuted(false);
await IOSAudioKeepAliveUtils.setTrack(index);
this.setState({
previewingIndex: index,
selectedTrackIndex: index,
previewLoading: false
});
return;
}
const status = await IOSAudioKeepAliveUtils.start();
if (!status?.isActive) {
this.setState({
previewingIndex: null,
previewLoading: false
});
return;
}
await IOSAudioKeepAliveUtils.setMuted(false);
await IOSAudioKeepAliveUtils.setTrack(index);
this.setState({
previewingIndex: index,
selectedTrackIndex: index,
previewStartedSession: true,
previewLoading: false
});
};
renderAudioTrackPicker() {
const {
availableTracks,
selectedTrackIndex,
previewingIndex,
previewLoading
} = this.state;
if (!IOSAudioKeepAliveUtils.isAvailable()) return null;
const textColor = themeColor('text');
const secondaryText = themeColor('secondaryText');
const highlight = themeColor('highlight');
const separator = themeColor('separator');
return (
<View style={{ marginTop: 28 }}>
<Text
style={{
color: textColor,
fontSize: 17,
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.Settings.NostrWalletConnect.backgroundAudio'
)}
</Text>
<Text
style={{
color: secondaryText,
fontSize: 14,
marginTop: 8,
lineHeight: 20,
fontFamily: 'PPNeueMontreal-Book'
}}
>
{localeString(
'views.Settings.NostrWalletConnect.backgroundAudioDescription'
)}
</Text>
<View style={{ marginTop: 16 }}>
{availableTracks.map((track) => {
const isSelected = selectedTrackIndex === track.index;
const isPreviewing = previewingIndex === track.index;
return (
<View
key={track.index}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 13,
borderBottomWidth: 1,
borderBottomColor: separator
}}
>
<TouchableOpacity
onPress={() =>
this.togglePreview(track.index)
}
hitSlop={8}
style={{ marginRight: 14 }}
>
{previewLoading &&
previewingIndex === track.index ? (
<ActivityIndicator
size="small"
color={highlight}
/>
) : (
<Icon
name={
isPreviewing
? 'stop-circle'
: 'play-circle'
}
type="material"
color={
isPreviewing
? highlight
: secondaryText
}
size={28}
/>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() =>
this.selectTrack(track.index)
}
activeOpacity={0.7}
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center'
}}
>
<Text
style={{
flex: 1,
color: isSelected
? highlight
: textColor,
fontSize: 16,
fontFamily: 'PPNeueMontreal-Book'
}}
>
{track.name}
</Text>
{isSelected && (
<Icon
name="check"
type="material"
color={highlight}
size={20}
/>
)}
</TouchableOpacity>
</View>
);
})}
</View>
</View>
);
}
render() {
const { navigation, SettingsStore } = this.props;
const {
@@ -365,6 +654,11 @@ export default class NWCSettings extends React.Component<
</Text>
</View>
)}
{/* iOS-only: background audio track picker */}
{Platform.OS === 'ios' && this.renderAudioTrackPicker()}
<View style={{ height: 30 }} />
</ScrollView>
)}
</Screen>