ENH - add configurable SaltyChat speaker support

This commit is contained in:
DerEchteAlec
2026-08-14 03:46:13 +02:00
parent 332060b8e3
commit aecadeec04
25 changed files with 818 additions and 49 deletions
+2 -2
View File
@@ -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.
+48
View File
@@ -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({
+26 -4
View File
@@ -101,12 +101,33 @@ export const useCallsStore = defineStore('calls', () => {
return response.success
}
async function setSpeaker(
enabled: boolean,
): Promise<NuiResponse<{ speakerEnabled: boolean }>> {
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<NuiResponse> {
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,
}
})
+13
View File
@@ -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.',
+50 -1
View File
@@ -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 })
+35
View File
@@ -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<RadioData>(structuredClone(defaults))
const error = ref('')
const isLoading = ref(false)
const speakerPending = ref(false)
const settingRequestIds: Record<keyof RadioSettings, number> = {
autoRejoin: 0,
notifications: 0,
}
let badgeRequestId = 0
let displayNameRequestId = 0
let speakerRequestId = 0
let volumeRequestId = 0
function apply(next: Partial<RadioData>): 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<boolean> {
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<void> {
const requestId = ++volumeRequestId
data.volume = volume
@@ -155,7 +188,9 @@ export const useRadioStore = defineStore('radio', () => {
saveBadge,
saveDisplayName,
saveSetting,
setSpeaker,
setVolume,
speakerPending,
updateMembers,
}
})
+2
View File
@@ -49,6 +49,8 @@ export type PhoneCall = {
direction: CallDirection
id: string
otherNumber: string
speakerEnabled?: boolean
speakerSupported?: boolean
startedAt: number
state: CallState
}
+2
View File
@@ -52,6 +52,8 @@ export type RadioData = {
provider: string | null
secondaryFrequency: number
secondarySupported: boolean
speakerEnabled?: boolean
speakerSupported?: boolean
settings: RadioSettings
volume: number
}
+34 -3
View File
@@ -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<void> {
}
}
async function toggleCallSpeaker(): Promise<void> {
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(() => {
<k-button
rounded
class="phone-call-action"
:class="{ 'is-active': callSpeakerEnabled }"
@click="callSpeakerEnabled = !callSpeakerEnabled"
:class="{
'is-active': calls.activeCall.speakerEnabled,
'is-disabled':
calls.activeCall.state !== 'connected' ||
!calls.activeCall.speakerSupported,
}"
:disabled="
calls.activeCall.state !== 'connected' ||
!calls.activeCall.speakerSupported ||
callSpeakerPending
"
:aria-pressed="calls.activeCall.speakerEnabled === true"
@click="toggleCallSpeaker"
>
<Volume2 />
<span>{{ phone.t('Apps.phone.speaker') }}</span>
+23
View File
@@ -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<void> {
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(() => {
<output for="radio-volume">{{ volumeInput }}%</output>
</SkyRange>
</SkyListItem>
<SkyListItem
v-if="radio.data.speakerSupported"
:title="phone.t('Apps.radio.speaker')"
:subtitle="phone.t('Apps.radio.speakerDescription')"
>
<template #media>
<Volume2 :size="20" aria-hidden="true" />
</template>
<template #after>
<SkyToggle
:model-value="radio.data.speakerEnabled"
:disabled="!radio.data.connected || radio.speakerPending"
:aria-label="phone.t('Apps.radio.speaker')"
@update:model-value="setSpeaker"
/>
</template>
</SkyListItem>
</SkyList>
<div class="radio-primary-action">
@@ -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)',
)
})
})
+26 -2
View File
@@ -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' ||
+7
View File
@@ -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(
+5 -1
View File
@@ -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,
+6
View File
@@ -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.",
+2
View File
@@ -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',
+58
View File
@@ -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
+48
View File
@@ -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
+166
View File
@@ -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
+6
View File
@@ -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",
+5 -11
View File
@@ -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
+7 -7
View File
@@ -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
+28
View File
@@ -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" })
+62 -1
View File
@@ -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
+51 -17
View File
@@ -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)