mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - add configurable SaltyChat speaker support
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,6 +49,8 @@ export type PhoneCall = {
|
||||
direction: CallDirection
|
||||
id: string
|
||||
otherNumber: string
|
||||
speakerEnabled?: boolean
|
||||
speakerSupported?: boolean
|
||||
startedAt: number
|
||||
state: CallState
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ export type RadioData = {
|
||||
provider: string | null
|
||||
secondaryFrequency: number
|
||||
secondarySupported: boolean
|
||||
speakerEnabled?: boolean
|
||||
speakerSupported?: boolean
|
||||
settings: RadioSettings
|
||||
volume: number
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user