mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - add muted vibration alerts
Play the supplied notification vibration asset for muted push banners and loop the supplied call vibration asset for muted incoming calls. Remove the synthesized vibration tone, add playback coverage, and provide a development preview for the muted notification path.
This commit is contained in:
Binary file not shown.
Binary file not shown.
+21
-11
@@ -1381,7 +1381,7 @@ onMounted(() => {
|
||||
}
|
||||
}, 1000)
|
||||
if (isDevelopment) {
|
||||
void hydrateDevelopmentPhone()
|
||||
const developmentHydration = hydrateDevelopmentPhone()
|
||||
if (developmentParameters.has('simPickerPreview')) {
|
||||
simPicker.value = {
|
||||
choices: [
|
||||
@@ -1403,16 +1403,26 @@ onMounted(() => {
|
||||
if (developmentParameters.has('payphonePreview')) {
|
||||
openDevelopmentPayphonePreview()
|
||||
}
|
||||
if (developmentParameters.has('notificationPreview')) {
|
||||
window.setTimeout(() => {
|
||||
notifications.show({
|
||||
appId: 'messages',
|
||||
persistent: true,
|
||||
route: '/apps/messages',
|
||||
text: 'You still got that spare alternator?',
|
||||
title: 'Tommy V',
|
||||
})
|
||||
}, 250)
|
||||
if (
|
||||
developmentParameters.has('notificationPreview') ||
|
||||
developmentParameters.has('mutedNotificationPreview')
|
||||
) {
|
||||
void developmentHydration.then(() => {
|
||||
if (developmentParameters.has('mutedNotificationPreview')) {
|
||||
phone.setAlertVolumes(0)
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
notifications.show({
|
||||
appId: 'messages',
|
||||
persistent: !developmentParameters.has(
|
||||
'mutedNotificationPreview',
|
||||
),
|
||||
route: '/apps/messages',
|
||||
text: 'You still got that spare alternator?',
|
||||
title: 'Tommy V',
|
||||
})
|
||||
}, 250)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,14 +2,16 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCallsStore } from '@/stores/calls'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { playPhoneTone } from '@/utils/tones'
|
||||
import { playPhoneTone, playPhoneVibration } from '@/utils/tones'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(async () => ({ success: true, data: [] })),
|
||||
}))
|
||||
vi.mock('@/utils/tones', () => ({
|
||||
playPhoneTone: vi.fn(() => vi.fn()),
|
||||
playPhoneVibration: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
|
||||
describe('calls store', () => {
|
||||
@@ -53,6 +55,23 @@ describe('calls store', () => {
|
||||
expect(stop).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('loops the vibration alert for incoming calls while globally muted', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.preferences.settings.notificationVolume = 0
|
||||
phone.preferences.settings.ringtoneVolume = 0
|
||||
const calls = useCallsStore()
|
||||
|
||||
calls.applyCallState({
|
||||
direction: 'incoming',
|
||||
id: 'call-muted',
|
||||
otherNumber: '1234567890',
|
||||
startedAt: 1,
|
||||
state: 'ringing',
|
||||
})
|
||||
|
||||
expect(playPhoneVibration).toHaveBeenCalledWith('call', true)
|
||||
})
|
||||
|
||||
it('clears terminal states and refreshes recents', async () => {
|
||||
const calls = useCallsStore()
|
||||
calls.applyCallState({
|
||||
|
||||
@@ -5,7 +5,11 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneCall, PhoneContact, RecentCall } from '@/types/phone'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
import type { RingtoneId } from '@/utils/preferences'
|
||||
import { playPhoneTone, type PhoneToneId } from '@/utils/tones'
|
||||
import {
|
||||
playPhoneTone,
|
||||
playPhoneVibration,
|
||||
type PhoneToneId,
|
||||
} from '@/utils/tones'
|
||||
|
||||
const RINGTONE_TONES: Record<RingtoneId, PhoneToneId> = {
|
||||
horizon: 'aurora',
|
||||
@@ -140,11 +144,16 @@ export const useCallsStore = defineStore('calls', () => {
|
||||
stopRingtone = null
|
||||
activeCall.value = call
|
||||
if (call.direction === 'incoming' && call.state === 'ringing') {
|
||||
stopRingtone = playPhoneTone(
|
||||
RINGTONE_TONES[phone.preferences.settings.ringtone],
|
||||
phone.preferences.settings.ringtoneVolume,
|
||||
true,
|
||||
)
|
||||
const alertsMuted =
|
||||
phone.preferences.settings.notificationVolume === 0 &&
|
||||
phone.preferences.settings.ringtoneVolume === 0
|
||||
stopRingtone = alertsMuted
|
||||
? playPhoneVibration('call', true)
|
||||
: playPhoneTone(
|
||||
RINGTONE_TONES[phone.preferences.settings.ringtone],
|
||||
phone.preferences.settings.ringtoneVolume,
|
||||
true,
|
||||
)
|
||||
}
|
||||
if (!['ringing', 'connected'].includes(call.state)) {
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
DEFAULT_PHONE_PREFERENCES,
|
||||
type PhonePreferencesV1,
|
||||
} from '@/utils/preferences'
|
||||
import { playPhoneVibration } from '@/utils/tones'
|
||||
|
||||
vi.mock('@/utils/tones', () => ({
|
||||
playPhoneTone: vi.fn(() => vi.fn()),
|
||||
playPhoneVibration: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
vi.mock('@/utils/nui', () => ({
|
||||
nuiCall: vi.fn(async () => ({ success: true, data: { revision: 1 } })),
|
||||
@@ -120,6 +122,23 @@ describe('notifications store', () => {
|
||||
expect(notifications.devicePreviews).toEqual([])
|
||||
})
|
||||
|
||||
it('plays a vibration alert for push notifications while globally muted', () => {
|
||||
const notifications = useNotificationsStore()
|
||||
const muted = device('111', (preferences) => {
|
||||
preferences.settings.notificationVolume = 0
|
||||
preferences.settings.ringtoneVolume = 0
|
||||
})
|
||||
|
||||
notifications.show({
|
||||
appId: 'messages',
|
||||
device: muted,
|
||||
text: 'Muted message',
|
||||
title: 'Messages',
|
||||
})
|
||||
|
||||
expect(playPhoneVibration).toHaveBeenCalledWith('notification', false)
|
||||
})
|
||||
|
||||
it('suppresses normal notifications during Focus but keeps critical alerts', () => {
|
||||
const phone = usePhoneStore()
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
DEFAULT_APP_NOTIFICATION_PREFERENCES,
|
||||
type PhonePreferencesV1,
|
||||
} from '@/utils/preferences'
|
||||
import { playPhoneTone, type PhoneToneId } from '@/utils/tones'
|
||||
import {
|
||||
playPhoneTone,
|
||||
playPhoneVibration,
|
||||
type PhoneToneId,
|
||||
} from '@/utils/tones'
|
||||
|
||||
export type PhoneNotificationDevice = {
|
||||
imei: string
|
||||
@@ -183,13 +187,21 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
preferences.settings.notifications[notification.appId] ??
|
||||
DEFAULT_APP_NOTIFICATION_PREFERENCES
|
||||
if (appPreferences.sounds || notification.critical) {
|
||||
const alertsMuted =
|
||||
preferences.settings.notificationVolume === 0 &&
|
||||
preferences.settings.ringtoneVolume === 0
|
||||
const sound = notification.sound ?? preferences.settings.notificationSound
|
||||
const volume = notification.critical
|
||||
? preferences.settings.ringtoneVolume
|
||||
: preferences.settings.notificationVolume
|
||||
stopToneHandles.set(
|
||||
notification.id,
|
||||
playPhoneTone(sound, volume, !!notification.persistent),
|
||||
alertsMuted
|
||||
? playPhoneVibration(
|
||||
'notification',
|
||||
!!notification.persistent,
|
||||
)
|
||||
: playPhoneTone(sound, volume, !!notification.persistent),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ALARM_SOUND_IDS } from './alarms'
|
||||
import { phoneToneDuration } from './tones'
|
||||
import { phoneToneDuration, playPhoneVibration } from './tones'
|
||||
|
||||
describe('phone tones', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('defines a playable duration for every alarm sound', () => {
|
||||
for (const sound of ALARM_SOUND_IDS) {
|
||||
expect(phoneToneDuration(sound)).toBeGreaterThan(0)
|
||||
expect(phoneToneDuration(sound)).toBeLessThanOrEqual(1500)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['notification', 'sounds/vibration-notification.mp3', false],
|
||||
['call', 'sounds/vibration-call.mp3', true],
|
||||
] as const)(
|
||||
'plays the %s vibration asset with the requested loop behavior',
|
||||
(kind, path, loop) => {
|
||||
const pause = vi.fn()
|
||||
const play = vi.fn(async () => undefined)
|
||||
const players: Array<{
|
||||
currentTime: number
|
||||
loop: boolean
|
||||
pause: () => void
|
||||
play: () => Promise<void>
|
||||
preload: string
|
||||
src: string
|
||||
volume: number
|
||||
}> = []
|
||||
vi.stubGlobal(
|
||||
'Audio',
|
||||
class {
|
||||
currentTime = 7
|
||||
loop = false
|
||||
pause = pause
|
||||
play = play
|
||||
preload = ''
|
||||
src: string
|
||||
volume = 0
|
||||
|
||||
constructor(src: string) {
|
||||
this.src = src
|
||||
players.push(this)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const stop = playPhoneVibration(kind, loop)
|
||||
|
||||
expect(players[0]).toMatchObject({
|
||||
loop,
|
||||
preload: 'auto',
|
||||
src: expect.stringContaining(path),
|
||||
volume: 1,
|
||||
})
|
||||
expect(play).toHaveBeenCalledOnce()
|
||||
stop()
|
||||
expect(pause).toHaveBeenCalledOnce()
|
||||
expect(players[0].currentTime).toBe(0)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AlarmSoundId } from '@/utils/alarms'
|
||||
import type { NotificationSoundId } from '@/utils/preferences'
|
||||
|
||||
export type PhoneToneId = AlarmSoundId | NotificationSoundId
|
||||
export type PhoneVibrationKind = 'call' | 'notification'
|
||||
|
||||
type ToneVoice = {
|
||||
detune?: number
|
||||
@@ -401,6 +402,11 @@ const TONE_PATTERNS: Record<PhoneToneId, TonePattern> = {
|
||||
},
|
||||
}
|
||||
|
||||
const VIBRATION_SOUND_PATHS: Record<PhoneVibrationKind, string> = {
|
||||
call: 'sounds/vibration-call.mp3',
|
||||
notification: 'sounds/vibration-notification.mp3',
|
||||
}
|
||||
|
||||
export function phoneToneDuration(tone: PhoneToneId): number {
|
||||
return Math.max(
|
||||
...TONE_PATTERNS[tone].steps.map((step) => step.offsetMs + step.durationMs),
|
||||
@@ -484,3 +490,27 @@ export function playPhoneTone(
|
||||
void context.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function playPhoneVibration(
|
||||
kind: PhoneVibrationKind,
|
||||
loop: boolean,
|
||||
): () => void {
|
||||
const player = new Audio(
|
||||
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
|
||||
)
|
||||
let stopped = false
|
||||
player.loop = loop
|
||||
player.preload = 'auto'
|
||||
player.volume = 1
|
||||
void player.play().catch((error: unknown) => {
|
||||
if (!stopped) {
|
||||
console.error('[Phone audio] Failed to start vibration sound', error)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
player.pause()
|
||||
player.currentTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user