diff --git a/README.md b/README.md index cda93b1..024eca9 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ and restart the resource after updating the configuration. - An inventory item named `phone`. It must be non-stackable when `Config.Phone.Unique = true` and may be stackable when it is `false`. - When `Config.Sim.Enabled = true`, two unique, non-stackable inventory items named `sky_phone_sim_registered` and `sky_phone_sim_anonymous`. Their metadata is initialized automatically on first use, so shops and crafting recipes add plain items without supplying a number. These item definitions are not required when SIM cards are disabled. - `oxmysql` with MySQL/MariaDB. -- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"`. +- `pma-voice` when `Config.Calls.VoiceProvider` is set to `"pma"` (the alias `"pma-voice"` selects the same adapter), or SaltyChat when it is set to `"saltychat"` (alias `"salty"`). SaltyChat additionally enables the in-call speaker control; PMA Voice keeps that control unavailable instead of simulating a local-only state. Set `Config.Speaker.Enabled = false` to disable the SaltyChat phone and radio speaker system globally; the controls then stay unavailable and server callbacks reject attempts to enable them. - A FiveManage V3 Media API token for Camera photo/video uploads, Voice Memo audio uploads, and Gallery deletion. Set it in the server-only `sky_phone/config/media.lua`; the token is never sent to NUI because clients receive temporary presigned upload URLs instead: @@ -243,7 +243,7 @@ deletion is retained as an audit-safe soft delete. Runtime migration creates ## Radio app -The built-in Radio app supports a primary frequency, volume, recent channels, participant lists, automatic rejoin, join/leave notifications, and an optional service number. YACA and SaltyChat support the configured secondary frequency; PMA Voice exposes one radio channel, so the secondary input is hidden automatically. +The built-in Radio app supports a primary frequency, volume, recent channels, participant lists, automatic rejoin, join/leave notifications, and an optional service number. YACA and SaltyChat support the configured secondary frequency; PMA Voice exposes one radio channel, so the secondary input is hidden automatically. SaltyChat also exposes the provider-backed radio speaker control when `Config.Speaker.Enabled` is enabled. The control is omitted when the global speaker system is disabled and for YACA or PMA Voice because those adapters do not provide an equivalent speaker API. Configure frequency bounds and precision, restricted channel ranges and allowed jobs, history length, defaults, badge validation, radio display-name permissions, and the built-in speaker HUD under `Config.Radio`. `Config.Radio.DisplayName.AllowedJobs` maps authoritative framework job names to their minimum grade. Unlisted jobs cannot change the name; an empty name restores the normal player or character name. Channel and display-name access are always checked server-side. `Config.Radio.Hud` controls the phone-owned overlay, its screen edge, offsets, and recent-speaker duration without depending on another HUD resource. Active-speaker highlighting uses the YACA radio events; the Radio app itself continues to support every configured voice provider. diff --git a/frontend/src/stores/calls.test.ts b/frontend/src/stores/calls.test.ts index 2b5c16c..e8df8f4 100644 --- a/frontend/src/stores/calls.test.ts +++ b/frontend/src/stores/calls.test.ts @@ -89,6 +89,54 @@ describe('calls store', () => { expect(nuiCall).toHaveBeenCalledWith('calls:recents') }) + it('applies the provider-authoritative speaker state for a connected call', async () => { + vi.mocked(nuiCall).mockResolvedValueOnce({ + success: true, + data: { speakerEnabled: true }, + }) + const calls = useCallsStore() + calls.applyCallState({ + direction: 'outgoing', + id: 'call-speaker', + otherNumber: '5551110025', + speakerEnabled: false, + speakerSupported: true, + startedAt: 1, + state: 'connected', + }) + + const response = await calls.setSpeaker(true) + + expect(response.success).toBe(true) + expect(nuiCall).toHaveBeenCalledWith('calls:set-speaker', { + enabled: true, + id: 'call-speaker', + }) + expect(calls.activeCall?.speakerEnabled).toBe(true) + }) + + it('does not simulate speaker state for unsupported voice providers', async () => { + const calls = useCallsStore() + calls.applyCallState({ + direction: 'outgoing', + id: 'call-pma', + otherNumber: '5551110025', + speakerEnabled: false, + speakerSupported: false, + startedAt: 1, + state: 'connected', + }) + + const response = await calls.setSpeaker(true) + + expect(response).toEqual({ + error: 'speaker_unavailable', + success: false, + }) + expect(nuiCall).not.toHaveBeenCalled() + expect(calls.activeCall?.speakerEnabled).toBe(false) + }) + it('updates a contact favorite and refreshes the contact list', async () => { vi.mocked(nuiCall) .mockResolvedValueOnce({ diff --git a/frontend/src/stores/calls.ts b/frontend/src/stores/calls.ts index 5ac0ca3..8d4edb8 100644 --- a/frontend/src/stores/calls.ts +++ b/frontend/src/stores/calls.ts @@ -101,12 +101,33 @@ export const useCallsStore = defineStore('calls', () => { return response.success } + async function setSpeaker( + enabled: boolean, + ): Promise> { + const call = activeCall.value + if (!call || call.state !== 'connected') { + return { success: false, error: 'call_not_connected' } + } + if (!call.speakerSupported) { + return { success: false, error: 'speaker_unavailable' } + } + + const response = await nuiCall<{ speakerEnabled: boolean }>( + 'calls:set-speaker', + { enabled, id: call.id }, + ) + if (response.success && response.data && activeCall.value?.id === call.id) { + activeCall.value = { + ...activeCall.value, + speakerEnabled: response.data.speakerEnabled === true, + } + } + return response + } + async function blockNumber(phoneNumber: string): Promise { const response = await nuiCall('calls:block', { phoneNumber }) - if ( - response.success && - activeCall.value?.otherNumber === phoneNumber - ) { + if (response.success && activeCall.value?.otherNumber === phoneNumber) { activeCall.value = null await loadRecents() } @@ -148,5 +169,6 @@ export const useCallsStore = defineStore('calls', () => { recents, saveContact, setContactFavorite, + setSpeaker, } }) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 1bae593..0f7e2f5 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1868,6 +1868,11 @@ const defaultLocales: LocaleTree = { readonly_contact: 'Official company contacts cannot be changed.', rate_limited: 'Too many calls. Try again in a minute.', voice_unavailable: 'The configured phone voice service is unavailable.', + call_not_connected: 'Connect the call before enabling speaker mode.', + speaker_unavailable: + 'Speaker mode is not available for the configured phone voice service.', + speaker_unsupported: + 'The configured phone voice service does not support speaker mode.', inventory_full: 'There is no room for the ejected SIM card.', operation_in_progress: 'Another phone operation is already in progress.', @@ -1895,6 +1900,8 @@ const defaultLocales: LocaleTree = { volume: 'Volume', connect: 'Connect', disconnect: 'Disconnect', + speaker: 'Speaker', + speakerDescription: 'Play radio traffic through speaker mode', members: 'Currently connected ({count})', noMembers: 'No participants', history: 'Recently connected', @@ -1927,6 +1934,12 @@ const defaultLocales: LocaleTree = { player_unavailable: 'Your player data is not available.', rate_limited: 'Please wait before changing channel again.', invalid_setting: 'This setting is invalid.', + radio_not_connected: + 'Connect to a radio channel before enabling speaker mode.', + speaker_unavailable: + 'Speaker mode is not available for the configured radio voice service.', + speaker_unsupported: + 'The configured radio voice service does not support speaker mode.', badge_disabled: 'Service numbers are disabled.', badge_forbidden: 'This service number is not allowed.', display_name_disabled: 'Radio display names are disabled.', diff --git a/frontend/src/stores/radio.test.ts b/frontend/src/stores/radio.test.ts index 8af3358..c72a0fc 100644 --- a/frontend/src/stores/radio.test.ts +++ b/frontend/src/stores/radio.test.ts @@ -23,9 +23,11 @@ const radioData: RadioData = { frequencyStep: 0.1, history: [{ primary: 120.5, secondary: 130.7 }], members: [], - provider: 'yaca', + provider: 'saltychat', secondaryFrequency: 0, secondarySupported: true, + speakerEnabled: false, + speakerSupported: true, settings: { autoRejoin: false, notifications: true }, volume: 50, } @@ -130,6 +132,53 @@ describe('radio store', () => { expect(radio.data.volume).toBe(75) }) + it('applies the provider-authoritative radio speaker state', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { speakerEnabled: true }, + success: true, + }) + const radio = useRadioStore() + radio.data.connected = true + radio.data.speakerSupported = true + + expect(await radio.setSpeaker(true)).toBe(true) + + expect(mockNuiCall).toHaveBeenCalledWith('radio:set-speaker', { + enabled: true, + }) + expect(radio.data.speakerEnabled).toBe(true) + expect(radio.speakerPending).toBe(false) + }) + + it('rolls radio speaker state back after provider rejection', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'voice_unavailable', + success: false, + }) + const radio = useRadioStore() + radio.data.connected = true + radio.data.speakerEnabled = false + radio.data.speakerSupported = true + + expect(await radio.setSpeaker(true)).toBe(false) + + expect(radio.data.speakerEnabled).toBe(false) + expect(radio.error).toBe('voice_unavailable') + expect(radio.speakerPending).toBe(false) + }) + + it('does not call NUI when the radio provider lacks speaker support', async () => { + const radio = useRadioStore() + radio.data.connected = true + radio.data.speakerSupported = false + + expect(await radio.setSpeaker(true)).toBe(false) + + expect(mockNuiCall).not.toHaveBeenCalled() + expect(radio.data.speakerEnabled).toBe(false) + expect(radio.error).toBe('speaker_unavailable') + }) + it('applies canonical profile values accepted by the server', async () => { mockNuiCall .mockResolvedValueOnce({ data: { badge: 'A_1' }, success: true }) diff --git a/frontend/src/stores/radio.ts b/frontend/src/stores/radio.ts index b259535..1a141d2 100644 --- a/frontend/src/stores/radio.ts +++ b/frontend/src/stores/radio.ts @@ -22,6 +22,8 @@ const defaults: RadioData = { provider: null, secondaryFrequency: 0, secondarySupported: true, + speakerEnabled: false, + speakerSupported: false, settings: { autoRejoin: false, notifications: false }, volume: 50, } @@ -30,12 +32,14 @@ export const useRadioStore = defineStore('radio', () => { const data = reactive(structuredClone(defaults)) const error = ref('') const isLoading = ref(false) + const speakerPending = ref(false) const settingRequestIds: Record = { autoRejoin: 0, notifications: 0, } let badgeRequestId = 0 let displayNameRequestId = 0 + let speakerRequestId = 0 let volumeRequestId = 0 function apply(next: Partial): void { @@ -81,10 +85,39 @@ export const useRadioStore = defineStore('radio', () => { frequency: 0, members: [], secondaryFrequency: 0, + speakerEnabled: false, }) + speakerRequestId += 1 + speakerPending.value = false isLoading.value = false } + async function setSpeaker(enabled: boolean): Promise { + if (!data.connected || !data.speakerSupported) { + error.value = 'speaker_unavailable' + return false + } + + const requestId = ++speakerRequestId + const previous = data.speakerEnabled === true + data.speakerEnabled = enabled + error.value = '' + speakerPending.value = true + const response = await nuiCall<{ speakerEnabled: boolean }>( + 'radio:set-speaker', + { enabled }, + ) + if (requestId !== speakerRequestId) return response.success + speakerPending.value = false + if (response.success && response.data) { + data.speakerEnabled = response.data.speakerEnabled === true + } else { + data.speakerEnabled = previous + error.value = response.error ?? 'request_failed' + } + return response.success + } + async function setVolume(volume: number): Promise { const requestId = ++volumeRequestId data.volume = volume @@ -155,7 +188,9 @@ export const useRadioStore = defineStore('radio', () => { saveBadge, saveDisplayName, saveSetting, + setSpeaker, setVolume, + speakerPending, updateMembers, } }) diff --git a/frontend/src/types/phone.ts b/frontend/src/types/phone.ts index a925024..bf9016a 100644 --- a/frontend/src/types/phone.ts +++ b/frontend/src/types/phone.ts @@ -49,6 +49,8 @@ export type PhoneCall = { direction: CallDirection id: string otherNumber: string + speakerEnabled?: boolean + speakerSupported?: boolean startedAt: number state: CallState } diff --git a/frontend/src/types/radio.ts b/frontend/src/types/radio.ts index c7c29e3..82fba3b 100644 --- a/frontend/src/types/radio.ts +++ b/frontend/src/types/radio.ts @@ -52,6 +52,8 @@ export type RadioData = { provider: string | null secondaryFrequency: number secondarySupported: boolean + speakerEnabled?: boolean + speakerSupported?: boolean settings: RadioSettings volume: number } diff --git a/frontend/src/views/apps/PhoneApp.vue b/frontend/src/views/apps/PhoneApp.vue index 989cf2f..59b4d93 100644 --- a/frontend/src/views/apps/PhoneApp.vue +++ b/frontend/src/views/apps/PhoneApp.vue @@ -96,7 +96,7 @@ const callKeypadOpened = ref(false) const inCallKeypad = ref('') const blockDialogOpened = ref(false) const blockTargetNumber = ref('') -const callSpeakerEnabled = ref(false) +const callSpeakerPending = ref(false) const callMuted = ref(false) const callElapsedSeconds = ref(0) let callClock: number | null = null @@ -471,6 +471,26 @@ async function answerCall(): Promise { } } +async function toggleCallSpeaker(): Promise { + const call = calls.activeCall + if ( + !call || + call.state !== 'connected' || + !call.speakerSupported || + callSpeakerPending.value + ) { + return + } + + error.value = '' + callSpeakerPending.value = true + const response = await calls.setSpeaker(!call.speakerEnabled) + callSpeakerPending.value = false + if (!response.success) { + error.value = phone.t(`Apps.phone.errors.${response.error ?? 'default'}`) + } +} + function updateCallElapsed(): void { const call = calls.activeCall if (!call || call.state !== 'connected') { @@ -784,8 +804,19 @@ onBeforeUnmount(() => { {{ phone.t('Apps.phone.speaker') }} diff --git a/frontend/src/views/apps/RadioApp.vue b/frontend/src/views/apps/RadioApp.vue index 2b40d22..a518579 100644 --- a/frontend/src/views/apps/RadioApp.vue +++ b/frontend/src/views/apps/RadioApp.vue @@ -31,6 +31,7 @@ import { SkySpinner, SkyStatusCard, SkyToast, + SkyToggle, } from '@/ui' import { isTrustedRootMessageSource } from '@/utils/windowMessages' @@ -110,6 +111,11 @@ function saveVolume(): void { void radio.setVolume(volumeInput.value) } +async function setSpeaker(enabled: boolean): Promise { + if (await radio.setSpeaker(enabled)) return + showFeedback(errorText(radio.error)) +} + function connectHistory(entry: RadioHistoryEntry): void { primaryInput.value = String(entry.primary) secondaryInput.value = entry.secondary ? String(entry.secondary) : '' @@ -303,6 +309,23 @@ onBeforeUnmount(() => { {{ volumeInput }}% + + + +
diff --git a/frontend/src/voiceProviders.contract.test.ts b/frontend/src/voiceProviders.contract.test.ts new file mode 100644 index 0000000..d3d5df8 --- /dev/null +++ b/frontend/src/voiceProviders.contract.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const manifest = readFileSync( + new URL('../../sky_phone/fxmanifest.lua', import.meta.url), + 'utf8', +) +const config = readFileSync( + new URL('../../sky_phone/config/config.lua', import.meta.url), + 'utf8', +) +const sharedBridge = readFileSync( + new URL('../../sky_phone/source/bridge/shared.lua', import.meta.url), + 'utf8', +) +const clientCalls = readFileSync( + new URL('../../sky_phone/source/bridge/client/calls.lua', import.meta.url), + 'utf8', +) +const clientMain = readFileSync( + new URL('../../sky_phone/source/client/main.lua', import.meta.url), + 'utf8', +) +const clientRadio = readFileSync( + new URL('../../sky_phone/source/bridge/client/radio.lua', import.meta.url), + 'utf8', +) +const radioNui = readFileSync( + new URL('../../sky_phone/source/client/radio.lua', import.meta.url), + 'utf8', +) +const serverCalls = readFileSync( + new URL('../../sky_phone/source/server/calls.lua', import.meta.url), + 'utf8', +) +const serverRadio = readFileSync( + new URL('../../sky_phone/source/server/radio.lua', import.meta.url), + 'utf8', +) +const serverVoice = readFileSync( + new URL('../../sky_phone/source/bridge/server/voice.lua', import.meta.url), + 'utf8', +) + +describe('voice provider contracts', () => { + it('loads the provider bridges before their call and radio consumers', () => { + expect(manifest.indexOf("'source/bridge/client/calls.lua'")).toBeLessThan( + manifest.indexOf("'source/client/payphones.lua'"), + ) + expect(manifest.indexOf("'source/bridge/server/voice.lua'")).toBeLessThan( + manifest.indexOf("'source/server/calls.lua'"), + ) + }) + + it('owns SaltyChat call membership and phone speaker state on the server', () => { + expect(serverVoice).toContain('exports.saltychat:AddPlayersToCall') + expect(serverVoice).toContain('exports.saltychat:RemovePlayersFromCall') + expect(serverVoice).toContain('exports.saltychat:SetPhoneSpeaker') + expect(serverCalls).toContain( + 'Bridge.Callbacks.Register("sky_phone:calls:set-speaker"', + ) + expect(serverCalls).toContain('local call_id = active_by_source[source]') + expect(serverCalls).toContain('call.id ~= data.id') + expect(serverCalls).toContain('Bridge.Calls.Stop(') + expect(serverCalls).toMatch( + /call\.speakers\[source\] = data\.enabled\s+send_state\(call, source, "connected", call\.channel\)/, + ) + expect(clientMain).toContain('"calls:set-speaker"') + expect(clientCalls).toContain( + 'SaltyChat call membership is owned by the server bridge.', + ) + }) + + it('supports one global server-authoritative speaker switch', () => { + expect(config).toMatch(/Config\.Speaker\s*=\s*\{\s*Enabled\s*=\s*true,/) + expect(sharedBridge).toContain('function Bridge.Speaker.IsEnabled()') + expect(clientCalls).toContain( + 'Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"', + ) + expect(clientRadio).toContain( + 'Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat"', + ) + expect(serverVoice).toContain( + 'Bridge.Speaker.IsEnabled() and resolve_call_provider() == "saltychat"', + ) + expect(serverVoice).toContain( + 'Bridge.Speaker.IsEnabled() and resolve_radio_provider() == "saltychat"', + ) + expect(serverCalls).toContain('if not Bridge.Speaker.IsEnabled() then') + }) + + it('uses the documented client and server Radio speaker exports', () => { + expect(clientRadio).toContain('exports.saltychat:GetRadioSpeaker()') + expect(clientRadio).toContain('exports.saltychat:SetRadioSpeaker(') + expect(serverVoice).toContain('exports.saltychat:SetPlayerRadioSpeaker(') + expect(radioNui).toContain('RegisterNUICallback("radio:set-speaker"') + expect(serverRadio).toContain( + 'Bridge.Callbacks.Register("sky_phone:radio:set-speaker"', + ) + expect(serverRadio).toContain('if not channels[source] then') + expect(serverRadio).toContain( + 'AddEventHandler("onResourceStop", function(resource_name)', + ) + }) +}) diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 229709d..566c4d5 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -68,9 +68,11 @@ const radioData = { { primary: 42.1, secondary: 0 }, ], members: [], - provider: 'yaca', + provider: 'saltychat', secondaryFrequency: 0, secondarySupported: true, + speakerEnabled: false, + speakerSupported: true, settings: { autoRejoin: false, notifications: true }, volume: 50, } @@ -5849,6 +5851,7 @@ app.post('/api/:endpoint', (request, response) => { radioData.frequency = 0 radioData.secondaryFrequency = 0 radioData.members = [] + radioData.speakerEnabled = false response.json({ success: true }) return } @@ -5857,6 +5860,18 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: { volume: radioData.volume } }) return } + if (endpoint === 'radio:set-speaker') { + if (!radioData.connected || !radioData.speakerSupported) { + response.json({ success: false, error: 'speaker_unavailable' }) + return + } + radioData.speakerEnabled = request.body.enabled === true + response.json({ + success: true, + data: { speakerEnabled: radioData.speakerEnabled }, + }) + return + } if (endpoint === 'radio:save-settings') { radioData.settings[request.body.key] = request.body.value === true response.json({ success: true, data: radioData.settings }) @@ -7971,7 +7986,7 @@ app.post('/api/:endpoint', (request, response) => { testScenario === 'citymarkt-local-pages-missing' ? ['local-pages'] : [], - version: 3, + version: 4, }, }, }, @@ -8506,6 +8521,8 @@ app.post('/api/:endpoint', (request, response) => { direction: 'outgoing', id, otherNumber: phoneNumber, + speakerEnabled: false, + speakerSupported: true, startedAt, state: 'ringing', }, @@ -8525,6 +8542,13 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: { blocked: true, phoneNumber } }) return } + if (endpoint === 'calls:set-speaker') { + response.json({ + success: true, + data: { speakerEnabled: request.body.enabled === true }, + }) + return + } if ( endpoint === 'calls:answer' || endpoint === 'calls:decline' || diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 9384c7f..1b2c204 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -308,6 +308,13 @@ async function verifyStatefulActions(baseUrl) { assert.equal(radio.frequency, 42.5) radio = await expectSuccess(baseUrl, 'radio:set-volume', { volume: 44 }, true) assert.equal(radio.volume, 44) + radio = await expectSuccess( + baseUrl, + 'radio:set-speaker', + { enabled: true }, + true, + ) + assert.equal(radio.speakerEnabled, true) await expectSuccess(baseUrl, 'radio:disconnect') const playlistState = await expectSuccess( diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index f997fa1..a865cf5 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -60,8 +60,12 @@ Config.Sim = { NumberGroups = { 3, 3, 4 }, } +Config.Speaker = { + Enabled = true, -- global phone and radio speaker controls +} + Config.Calls = { - VoiceProvider = "pma", + VoiceProvider = "pma", -- pma (alias: pma-voice), saltychat (alias: salty) RingSeconds = 30, ContactNameMaxLength = 80, ContactNotesMaxLength = 500, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 3f066d9..afdb1f8 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -875,6 +875,8 @@ Locales["en"] = { message_unavailable = "The conversation could not be opened.", contact_remove_failed = "The contact could not be removed.", contact_favorite_failed = "The favorite could not be updated.", blocked = "This number is blocked.", recipient_not_found = "This number is not known.", rate_limited = "Too many calls. Try again in a minute.", voice_unavailable = "The configured phone voice service is unavailable.", + call_not_connected = "Connect the call before enabling speaker mode.", speaker_unavailable = "Speaker mode is not available for the configured phone voice service.", + speaker_unsupported = "The configured phone voice service does not support speaker mode.", inventory_full = "There is no room for the ejected SIM card.", request_failed = "The phone request failed.", operation_in_progress = "Another phone operation is already in progress.", sim_request_expired = "The SIM selection expired. Use the SIM card again.", sim_not_owned = "That SIM card is no longer in your inventory.", phone_not_owned = "That phone is no longer in your inventory.", @@ -887,6 +889,7 @@ Locales["en"] = { noProvider = "Voice service unavailable", channel = "Channel", primaryFrequency = "Primary frequency", secondaryFrequency = "Secondary frequency", frequencyPlaceholder = "e.g. 120.5", optional = "Optional", mhz = "MHz", volume = "Volume", connect = "Connect", disconnect = "Disconnect", + speaker = "Speaker", speakerDescription = "Play radio traffic through speaker mode", members = "Currently connected ({count})", noMembers = "No participants", history = "Recently connected", noHistory = "No history", badge = "Service number", badgePlaceholder = "e.g. 231", profileSaved = "Radio profile saved", otherSettings = "Other", autoRejoin = "Automatic rejoin", @@ -902,6 +905,9 @@ Locales["en"] = { channel_locked = "You do not have access to this channel.", secondary_locked = "You do not have access to the secondary channel.", voice_unavailable = "The configured radio voice service is unavailable.", player_unavailable = "Your player data is not available.", rate_limited = "Please wait before changing channel again.", invalid_setting = "This setting is invalid.", + radio_not_connected = "Connect to a radio channel before enabling speaker mode.", + speaker_unavailable = "Speaker mode is not available for the configured radio voice service.", + speaker_unsupported = "The configured radio voice service does not support speaker mode.", badge_disabled = "Service numbers are disabled.", badge_forbidden = "This service number is not allowed.", display_name_disabled = "Radio display names are disabled.", display_name_forbidden = "Your job or grade cannot change the radio display name.", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 600d5b8..e93ca72 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -29,6 +29,7 @@ client_scripts { 'source/bridge/client/callbacks.lua', 'source/bridge/client/housing.lua', 'source/bridge/client/housing/*.lua', + 'source/bridge/client/calls.lua', 'source/client/animations.lua', 'source/client/focus.lua', 'source/client/camera.lua', @@ -62,6 +63,7 @@ server_scripts { 'source/bridge/server/housing/*.lua', 'source/bridge/server/inventory.lua', 'source/bridge/server/inventory/*.lua', + 'source/bridge/server/voice.lua', 'source/server/custom_apps.lua', 'source/server/custom_app_compat.lua', 'source/server/db_migrate.lua', diff --git a/sky_phone/source/bridge/client/calls.lua b/sky_phone/source/bridge/client/calls.lua new file mode 100644 index 0000000..65ed89d --- /dev/null +++ b/sky_phone/source/bridge/client/calls.lua @@ -0,0 +1,58 @@ +local provider_resources = { + pma = "pma-voice", + saltychat = "saltychat", +} +local provider_aliases = { + ["pma-voice"] = "pma", + salty = "saltychat", +} + +local function resolve_provider() + local configured = tostring(Config.Calls.VoiceProvider or "") + local selected = provider_aliases[configured] or configured + local resource_name = provider_resources[selected] + if resource_name and GetResourceState(resource_name) == "started" then + return selected + end + return nil +end + +function Bridge.Calls.GetProvider() + return resolve_provider() +end + +function Bridge.Calls.SupportsSpeaker() + return Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat" +end + +function Bridge.Calls.Join(channel) + local selected = resolve_provider() + if selected == "pma" then + local call_channel = tonumber(channel) or 0 + if call_channel <= 0 then + Bridge.Debug("error", "[sky_phone] Refused to join an invalid PMA call channel.", { always = true }) + return false + end + exports["pma-voice"]:setCallChannel(call_channel) + return true + end + + if selected == "saltychat" then + -- SaltyChat call membership is owned by the server bridge. + return true + end + + Bridge.Debug( + "error", + "[sky_phone] Configured call voice provider '%s' is not supported or not started.", + tostring(Config.Calls.VoiceProvider), + { always = true } + ) + return false +end + +function Bridge.Calls.Leave() + if resolve_provider() == "pma" then + exports["pma-voice"]:setCallChannel(0) + end +end diff --git a/sky_phone/source/bridge/client/radio.lua b/sky_phone/source/bridge/client/radio.lua index 4c10413..074707f 100644 --- a/sky_phone/source/bridge/client/radio.lua +++ b/sky_phone/source/bridge/client/radio.lua @@ -50,6 +50,53 @@ function Bridge.Radio.SupportsSecondary() return Config.Radio.AllowSecondary and (selected == "yaca" or selected == "saltychat") end +function Bridge.Radio.SupportsSpeaker() + return Bridge.Speaker.IsEnabled() and resolve_provider() == "saltychat" +end + +function Bridge.Radio.GetSpeaker() + if not Bridge.Speaker.IsEnabled() or resolve_provider() ~= "saltychat" then + return false + end + + local success, enabled = pcall(function() + return exports.saltychat:GetRadioSpeaker() + end) + if not success then + Bridge.Debug( + "error", + "[sky_phone] SaltyChat could not read the radio speaker state: %s", + tostring(enabled), + { always = true } + ) + return false + end + return enabled == true +end + +function Bridge.Radio.SetSpeaker(enabled) + if enabled == true and not Bridge.Speaker.IsEnabled() then + return false + end + if resolve_provider() ~= "saltychat" then + return false + end + + local success, error_message = pcall(function() + exports.saltychat:SetRadioSpeaker(enabled == true) + end) + if not success then + Bridge.Debug( + "error", + "[sky_phone] SaltyChat could not update the local radio speaker state: %s", + tostring(error_message), + { always = true } + ) + return false + end + return true +end + function Bridge.Radio.Join(primary, secondary) local selected = resolve_provider() if selected == "yaca" then @@ -94,6 +141,7 @@ function Bridge.Radio.Leave() elseif selected == "pma" then exports["pma-voice"]:setRadioChannel(0) elseif selected == "saltychat" then + Bridge.Radio.SetSpeaker(false) exports.saltychat:SetRadioChannel("", true) exports.saltychat:SetRadioChannel("", false) end diff --git a/sky_phone/source/bridge/server/voice.lua b/sky_phone/source/bridge/server/voice.lua new file mode 100644 index 0000000..c1672fa --- /dev/null +++ b/sky_phone/source/bridge/server/voice.lua @@ -0,0 +1,166 @@ +local call_provider_resources = { + pma = "pma-voice", + saltychat = "saltychat", +} +local call_provider_aliases = { + ["pma-voice"] = "pma", + salty = "saltychat", +} +local radio_provider_resources = { + yaca = "yaca-voice", + pma = "pma-voice", + saltychat = "saltychat", +} +local radio_provider_aliases = { + ["yaca-voice"] = "yaca", + ["pma-voice"] = "pma", + salty = "saltychat", +} + +local function resolve_call_provider() + local configured = tostring(Config.Calls.VoiceProvider or "") + local selected = call_provider_aliases[configured] or configured + local resource_name = call_provider_resources[selected] + if resource_name and GetResourceState(resource_name) == "started" then + return selected + end + return nil +end + +local function resolve_radio_provider() + local configured = tostring(Config.Radio.VoiceProvider or "") + if configured ~= "auto" then + local selected = radio_provider_aliases[configured] or configured + local resource_name = radio_provider_resources[selected] + if resource_name and GetResourceState(resource_name) == "started" then + return selected + end + return nil + end + + for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do + if GetResourceState(radio_provider_resources[candidate]) == "started" then + return candidate + end + end + return nil +end + +function Bridge.Calls.GetProvider() + return resolve_call_provider() +end + +function Bridge.Calls.IsAvailable() + return resolve_call_provider() ~= nil +end + +function Bridge.Calls.SupportsSpeaker() + return Bridge.Speaker.IsEnabled() and resolve_call_provider() == "saltychat" +end + +function Bridge.Calls.Start(identifier, player_handles) + local selected = resolve_call_provider() + if selected == "pma" then + return true, selected + end + if selected ~= "saltychat" then + return false, nil + end + + local success, error_message = pcall(function() + exports.saltychat:AddPlayersToCall(tostring(identifier), player_handles) + end) + if not success then + Bridge.Debug( + "error", + "[sky_phone] SaltyChat could not add players to call %s: %s", + tostring(identifier), + tostring(error_message), + { always = true } + ) + return false, selected + end + return true, selected +end + +function Bridge.Calls.Stop(identifier, player_handles, provider) + local selected = provider or resolve_call_provider() + if selected ~= "saltychat" or GetResourceState("saltychat") ~= "started" then + return + end + + local success, error_message = pcall(function() + exports.saltychat:RemovePlayersFromCall(tostring(identifier), player_handles) + end) + if not success then + Bridge.Debug( + "error", + "[sky_phone] SaltyChat could not remove players from call %s: %s", + tostring(identifier), + tostring(error_message), + { always = true } + ) + end +end + +function Bridge.Calls.SetSpeaker(player_source, enabled, provider) + if enabled == true and not Bridge.Speaker.IsEnabled() then + return false + end + local selected = provider or resolve_call_provider() + if selected ~= "saltychat" or GetResourceState("saltychat") ~= "started" then + return false + end + + local success, error_message = pcall(function() + exports.saltychat:SetPhoneSpeaker(tonumber(player_source), enabled == true) + end) + if not success then + Bridge.Debug( + "error", + "[sky_phone] SaltyChat could not update the phone speaker for source %s: %s", + tostring(player_source), + tostring(error_message), + { always = true } + ) + return false + end + return true +end + +function Bridge.Radio.GetProvider() + return resolve_radio_provider() +end + +function Bridge.Radio.SupportsSecondary() + local selected = resolve_radio_provider() + return Config.Radio.AllowSecondary and (selected == "yaca" or selected == "saltychat") +end + +function Bridge.Radio.SupportsSpeaker() + return Bridge.Speaker.IsEnabled() and resolve_radio_provider() == "saltychat" +end + +function Bridge.Radio.SetPlayerSpeaker(player_source, enabled) + if enabled == true and not Bridge.Speaker.IsEnabled() then + return false + end + if resolve_radio_provider() ~= "saltychat" then + return false + end + + local success, error_message = pcall(function() + exports.saltychat:SetPlayerRadioSpeaker(tonumber(player_source), enabled == true) + end) + if not success then + Bridge.Debug( + "error", + "[sky_phone] SaltyChat could not update the radio speaker for source %s: %s", + tostring(player_source), + tostring(error_message), + { always = true } + ) + return false + end + return true +end diff --git a/sky_phone/source/bridge/shared.lua b/sky_phone/source/bridge/shared.lua index 649e169..9d8c653 100644 --- a/sky_phone/source/bridge/shared.lua +++ b/sky_phone/source/bridge/shared.lua @@ -1,9 +1,15 @@ Bridge = Bridge or {} Bridge.Callbacks = Bridge.Callbacks or {} +Bridge.Calls = Bridge.Calls or {} Bridge.Database = Bridge.Database or {} Bridge.Framework = Bridge.Framework or {} Bridge.Inventory = Bridge.Inventory or {} Bridge.Radio = Bridge.Radio or {} +Bridge.Speaker = Bridge.Speaker or {} + +function Bridge.Speaker.IsEnabled() + return not Config.Speaker or Config.Speaker.Enabled ~= false +end local level_colours = { debug = "^5", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index f46b264..7a390d3 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -222,6 +222,7 @@ local server_callbacks = { "calls:recents", "calls:dial", "calls:answer", + "calls:set-speaker", "calls:decline", "calls:hangup", "calls:block", @@ -376,23 +377,16 @@ local function leave_call_voice() if call_channel == 0 then return end - if Config.Calls.VoiceProvider == "pma" and GetResourceState("pma-voice") == "started" then - exports["pma-voice"]:setCallChannel(0) - end + Bridge.Calls.Leave() call_channel = 0 end local function join_call_voice(channel) - if Config.Calls.VoiceProvider ~= "pma" then - Bridge.Debug("error", "[sky_phone] Unsupported voice provider '%s'.", tostring(Config.Calls.VoiceProvider)) + local next_channel = tonumber(channel) or 0 + if not Bridge.Calls.Join(next_channel) then return false end - if GetResourceState("pma-voice") ~= "started" then - Bridge.Debug("error", "[sky_phone] Configured pma-voice provider is not started.") - return false - end - call_channel = tonumber(channel) or 0 - exports["pma-voice"]:setCallChannel(call_channel) + call_channel = next_channel return true end diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua index d59b569..df2295f 100644 --- a/sky_phone/source/client/payphones.lua +++ b/sky_phone/source/client/payphones.lua @@ -115,19 +115,19 @@ local function leave_call_voice() if call_channel == 0 then return end - if Config.Calls.VoiceProvider == "pma" and GetResourceState("pma-voice") == "started" then - exports["pma-voice"]:setCallChannel(0) - end + Bridge.Calls.Leave() call_channel = 0 end local function join_call_voice(channel) - if Config.Calls.VoiceProvider ~= "pma" or GetResourceState("pma-voice") ~= "started" then - Bridge.Debug("error", "[sky_phone] The payphone call could not join the configured voice provider.") + local next_channel = tonumber(channel) or 0 + if not Bridge.Calls.Join(next_channel) then + Bridge.Debug("error", "[sky_phone] The payphone call could not join the configured voice provider.", { + always = true, + }) return false end - call_channel = tonumber(channel) or 0 - exports["pma-voice"]:setCallChannel(call_channel) + call_channel = next_channel return call_channel > 0 end diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua index 2b087cf..d9b9496 100644 --- a/sky_phone/source/client/radio.lua +++ b/sky_phone/source/client/radio.lua @@ -140,6 +140,11 @@ local function join_radio(primary, secondary) data.secondaryFrequency = approved_secondary data.provider = Bridge.Radio.GetProvider() data.secondarySupported = Bridge.Radio.SupportsSecondary() + data.speakerSupported = Bridge.Radio.SupportsSpeaker() + data.speakerEnabled = data.speakerSupported and data.speakerEnabled == true + if data.speakerSupported and Bridge.Radio.GetSpeaker() ~= data.speakerEnabled then + Bridge.Radio.SetSpeaker(data.speakerEnabled) + end return { success = true, data = data } end @@ -162,6 +167,11 @@ RegisterNUICallback("radio:get", function(data, cb) result.data.volume = current_volume result.data.provider = Bridge.Radio.GetProvider() result.data.secondarySupported = Bridge.Radio.SupportsSecondary() + result.data.speakerSupported = Bridge.Radio.SupportsSpeaker() + result.data.speakerEnabled = result.data.speakerSupported and result.data.speakerEnabled == true + if result.data.speakerSupported and Bridge.Radio.GetSpeaker() ~= result.data.speakerEnabled then + Bridge.Radio.SetSpeaker(result.data.speakerEnabled) + end elseif result.success then result = { success = false, error = "request_failed" } end @@ -195,6 +205,24 @@ RegisterNUICallback("radio:set-volume", function(data, cb) cb({ success = true, data = { volume = current_volume } }) end) +RegisterNUICallback("radio:set-speaker", function(data, cb) + if type(data) ~= "table" or type(data.enabled) ~= "boolean" then + cb({ success = false, error = "invalid_request" }) + return + end + local result = request("set-speaker", { enabled = data.enabled }) + if not result.success then + cb(result) + return + end + Bridge.Radio.SetSpeaker(data.enabled) + result.data = { + speakerEnabled = data.enabled, + speakerSupported = true, + } + cb(result) +end) + RegisterNUICallback("radio:save-settings", function(data, cb) if type(data) ~= "table" then cb({ success = false, error = "invalid_request" }) diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua index b81e731..9eace16 100644 --- a/sky_phone/source/server/calls.lua +++ b/sky_phone/source/server/calls.lua @@ -91,6 +91,10 @@ end local function send_state(call, source, state, channel) local outgoing = source == call.caller_source + local speaker_supported = Bridge.Speaker.IsEnabled() and ( + call.voice_provider == "saltychat" + or (not call.voice_provider and Bridge.Calls.SupportsSpeaker()) + ) local payload = { id = call.id, state = state, @@ -99,6 +103,8 @@ local function send_state(call, source, state, channel) startedAt = call.started_at, answeredAt = call.answered_at, channel = channel, + speakerEnabled = call.speakers and call.speakers[source] == true or false, + speakerSupported = speaker_supported, } if call.payphone and outgoing then payload.elapsedSeconds = call.payphone.elapsed_seconds or 0 @@ -216,6 +222,17 @@ local function finish_call(call, status) return end call.ended = true + if call.voice_started then + local player_handles = { call.caller_source, call.callee_source } + for _, player_source in ipairs(player_handles) do + if call.speakers and call.speakers[player_source] then + Bridge.Calls.SetSpeaker(player_source, false, call.voice_provider) + end + end + Bridge.Calls.Stop(call.id, player_handles, call.voice_provider) + call.speakers = {} + call.voice_started = false + end local ended_at = os.time() local duration = call.answered_at and math.max(0, ended_at - call.answered_at) or 0 if call.payphone and call.answered_at and not settle_payphone_call(call, duration) @@ -1199,9 +1216,19 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data) finish_call(call, "unavailable") return { success = false, error = "phone_not_owned" } end - if Config.Calls.VoiceProvider ~= "pma" or GetResourceState("pma-voice") ~= "started" then + if not Bridge.Calls.IsAvailable() then return { success = false, error = "voice_unavailable" } end + local voice_started, voice_provider = Bridge.Calls.Start(call.id, { + call.caller_source, + call.callee_source, + }) + if not voice_started then + return { success = false, error = "voice_unavailable" } + end + call.voice_provider = voice_provider + call.voice_started = true + call.speakers = {} call.answered_at = os.time() call.channel = next_voice_channel next_voice_channel = next_voice_channel + 1 @@ -1223,6 +1250,40 @@ Bridge.Callbacks.Register("sky_phone:calls:answer", function(source, data) return { success = true } end) +Bridge.Callbacks.Register("sky_phone:calls:set-speaker", function(source, data) + if type(data) ~= "table" or type(data.id) ~= "string" or type(data.enabled) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + if not SkyPhone.AllowOperation(source, "call_speaker", 30, 60) then + return { success = false, error = "rate_limited" } + end + + local call_id = active_by_source[source] + local call = call_id and calls[call_id] or nil + if not call or call.id ~= data.id or not call.answered_at or call.ended or not call.voice_started then + return { success = false, error = "call_not_found" } + end + if call.voice_provider ~= "saltychat" then + return { success = false, error = "speaker_unsupported" } + end + if not Bridge.Speaker.IsEnabled() then + return { success = false, error = "speaker_unsupported" } + end + if not Bridge.Calls.SetSpeaker(source, data.enabled, call.voice_provider) then + return { success = false, error = "voice_unavailable" } + end + + call.speakers[source] = data.enabled + send_state(call, source, "connected", call.channel) + return { + success = true, + data = { + speakerEnabled = data.enabled, + speakerSupported = true, + }, + } +end) + Bridge.Callbacks.Register("sky_phone:calls:decline", function(source, data) local call = type(data) == "table" and calls[data.id] or nil if not call or call.callee_source ~= source or call.answered_at or call.rerouting then diff --git a/sky_phone/source/server/radio.lua b/sky_phone/source/server/radio.lua index 55be221..09f6b32 100644 --- a/sky_phone/source/server/radio.lua +++ b/sky_phone/source/server/radio.lua @@ -2,25 +2,10 @@ local profiles = {} local channels = {} local joined_at = {} local last_requests = {} +local speaker_states = {} local function supports_secondary() - if not Config.Radio.AllowSecondary then - return false - end - local configured = Config.Radio.VoiceProvider - if configured == "pma" or configured == "pma-voice" then - return false - end - if configured ~= "auto" then - return true - end - if GetResourceState("yaca-voice") == "started" then - return true - end - if GetResourceState("pma-voice") == "started" then - return false - end - return GetResourceState("saltychat") == "started" + return Bridge.Radio.SupportsSecondary() end local function default_profile() @@ -306,6 +291,8 @@ Bridge.Callbacks.Register("sky_phone:radio:get", function(source) frequencyStep = 1 / (10 ^ Config.Radio.FrequencyDecimals), savedFrequency = profile.primaryFrequency, savedSecondaryFrequency = profile.secondaryFrequency, + speakerEnabled = speaker_states[source] == true, + speakerSupported = Bridge.Radio.SupportsSpeaker(), }, } end) @@ -375,11 +362,17 @@ Bridge.Callbacks.Register("sky_phone:radio:connect", function(source, data) secondaryFrequency = secondary, members = get_members(primary), history = profile.history, + speakerEnabled = speaker_states[source] == true, + speakerSupported = Bridge.Radio.SupportsSpeaker(), }, } end) Bridge.Callbacks.Register("sky_phone:radio:disconnect", function(source) + if speaker_states[source] then + Bridge.Radio.SetPlayerSpeaker(source, false) + end + speaker_states[source] = nil remove_from_channels(source) local identifier, profile = load_profile(source) if profile then @@ -390,6 +383,32 @@ Bridge.Callbacks.Register("sky_phone:radio:disconnect", function(source) return { success = true } end) +Bridge.Callbacks.Register("sky_phone:radio:set-speaker", function(source, data) + if type(data) ~= "table" or type(data.enabled) ~= "boolean" then + return { success = false, error = "invalid_request" } + end + if rate_limited(source, "set-speaker", 250) then + return { success = false, error = "rate_limited" } + end + if not channels[source] then + return { success = false, error = "radio_not_connected" } + end + if not Bridge.Radio.SupportsSpeaker() then + return { success = false, error = "speaker_unsupported" } + end + if not Bridge.Radio.SetPlayerSpeaker(source, data.enabled) then + return { success = false, error = "voice_unavailable" } + end + speaker_states[source] = data.enabled + return { + success = true, + data = { + speakerEnabled = data.enabled, + speakerSupported = true, + }, + } +end) + Bridge.Callbacks.Register("sky_phone:radio:save-settings", function(source, data) local key = tostring(data.key or "") if key ~= "autoRejoin" and key ~= "notifications" then @@ -464,6 +483,10 @@ end) AddEventHandler("playerDropped", function() local player_source = source + if speaker_states[player_source] then + Bridge.Radio.SetPlayerSpeaker(player_source, false) + end + speaker_states[player_source] = nil local identifier = Bridge.Framework.GetIdentifier(player_source) remove_from_channels(player_source) if identifier then @@ -475,3 +498,14 @@ AddEventHandler("playerDropped", function() end end end) + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name ~= GetCurrentResourceName() then + return + end + for player_source, enabled in pairs(speaker_states) do + if enabled then + Bridge.Radio.SetPlayerSpeaker(player_source, false) + end + end +end)