From fc859888c3b7dd058c3546e0b0e582dc1e930d34 Mon Sep 17 00:00:00 2001 From: DerEchteAlec Date: Thu, 6 Aug 2026 19:46:34 +0200 Subject: [PATCH 1/3] ADD - integrate configurable radio app --- README.md | 9 + frontend/src/assets/img/app-icons/radio.svg | 15 + frontend/src/config/apps.test.ts | 6 + frontend/src/config/apps.ts | 15 + frontend/src/stores/phone.ts | 56 ++ frontend/src/stores/radio.ts | 142 +++++ frontend/src/types/apps.ts | 1 + frontend/src/types/radio.ts | 39 ++ frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/RadioApp.vue | 572 ++++++++++++++++++ frontend/testserver/index.cjs | 82 +++ sky_phone/config/config.lua | 46 ++ sky_phone/config/locales/en.lua | 27 + sky_phone/fxmanifest.lua | 3 + sky_phone/source/bridge/client/radio.lua | 120 ++++ .../source/bridge/server/frameworks/esx.lua | 14 + .../source/bridge/server/frameworks/qb.lua | 12 + .../source/bridge/server/frameworks/qbox.lua | 12 + sky_phone/source/bridge/shared.lua | 1 + sky_phone/source/client/radio.lua | 184 ++++++ sky_phone/source/html/index.html | 4 +- sky_phone/source/server/db_migrate.lua | 18 + sky_phone/source/server/radio.lua | 502 +++++++++++++++ sky_phone/sql/install.sql | 12 + 24 files changed, 1891 insertions(+), 2 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/radio.svg create mode 100644 frontend/src/stores/radio.ts create mode 100644 frontend/src/types/radio.ts create mode 100644 frontend/src/views/apps/RadioApp.vue create mode 100644 sky_phone/source/bridge/client/radio.lua create mode 100644 sky_phone/source/client/radio.lua create mode 100644 sky_phone/source/server/radio.lua diff --git a/README.md b/README.md index 9570b0c..b87a179 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ An iFruit account is optional. Unlinked devices retain local settings, alarms, m - A FiveManage V3 Media API token for Camera photo/video uploads and Gallery deletion. Set the server-only `Config.Media.FiveManage.ApiKey` in `sky_phone/config/media.lua`; the token is never sent to NUI because clients receive temporary presigned upload URLs instead. +- `yaca-voice`, `pma-voice`, or `saltychat` when the Radio app is enabled. `Config.Radio.VoiceProvider = "auto"` selects the first running provider in that order. Database migrations run automatically. Existing `sky_phone_mail_accounts` installations are renamed to `sky_phone_accounts` while preserving account IDs and mail foreign keys. iFruit passwords are intentional in-character credentials and remain plaintext `VARCHAR(64)` values; registration screens warn players never to reuse a real password. @@ -27,6 +28,14 @@ For a fresh manual database installation, import `sky_phone/sql/install.sql`. It Framework, inventory, callback, notification, and database integrations live under `sky_phone/source/bridge`. The resource has no dependency on any other Sky resource. +## 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. + +Configure frequency bounds and precision, restricted channel ranges and allowed jobs, history length, defaults, badge validation, radio display-name permissions, and the optional HUD integration 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. Setting `Config.Radio.Hud.Enabled = false` disables external HUD notifications while the phone-owned `GetPlayerBadge` and `GetPlayerRadioDisplayName` server exports remain available. + +Radio profiles are stored in `sky_phone_radio_profiles`. Runtime migration creates the table automatically; fresh installations receive it through `sky_phone/sql/install.sql`. + Inventory metadata has no framework-wide standard: providers differ in export names, callback payloads, slot handling, and whether metadata is called `metadata` or `info`. For that reason, `sky_phone` uses explicit provider adapters instead of guessing exports at runtime. Every supported adapter implements slot lookup, item lookup, metadata replacement, capacity handling, add/remove operations, and usable-item registration. Providers without a separate capacity export use their authoritative add operation as the final capacity gate. When a SIM is ejected or replaced, the returned inventory item is rebuilt from the authoritative `sky_phone_sims` row. Its metadata contains `sim_metadata_version`, `sim_id`, `phone_number`, `formatted_number`, and `sim_type`. Registered SIMs additionally contain `firstname`, `lastname`, `birthdate`, and `registered_at`. The internal framework owner identifier remains database-only. Inserting the item again resolves the SIM by `sim_id`; contacts and device/cloud data remain attached to their existing phone-owned persistence instead of being copied into inventory metadata. diff --git a/frontend/src/assets/img/app-icons/radio.svg b/frontend/src/assets/img/app-icons/radio.svg new file mode 100644 index 0000000..001fab2 --- /dev/null +++ b/frontend/src/assets/img/app-icons/radio.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index a5fd6bd..0a60dac 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -35,6 +35,12 @@ describe('app registry', () => { labelKey: 'Apps.calendar.name', route: '/apps/calendar', }) + expect(PHONE_APPS.find((app) => app.id === 'radio')).toMatchObject({ + dockOrder: null, + gridOrder: 21, + labelKey: 'Apps.radio.name', + route: '/apps/radio', + }) expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({ dockOrder: null, gridOrder: 11, diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 9377063..1b048d3 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -14,6 +14,7 @@ import { MapPinned, NotebookPen, Phone, + RadioTower, Settings, ShoppingBag, CloudSun, @@ -31,6 +32,7 @@ import calendarIcon from '@/assets/img/app-icons/calendar.svg' import mailIcon from '@/assets/img/app-icons/mail.webp' import mapIcon from '@/assets/img/app-icons/map.webp' import notesIcon from '@/assets/img/app-icons/notes.webp' +import radioIcon from '@/assets/img/app-icons/radio.svg' import photosIcon from '@/assets/img/app-icons/gallery.webp' import phoneIcon from '@/assets/img/app-icons/phone.webp' import settingsIcon from '@/assets/img/app-icons/settings.webp' @@ -77,6 +79,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.localPages.name', route: '/apps/local-pages', }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/RadioApp.vue')), + ), + dockOrder: null, + gridOrder: 21, + icon: markRaw(RadioTower), + iconClass: 'app-icon--radio', + iconImage: radioIcon, + id: 'radio', + labelKey: 'Apps.radio.name', + route: '/apps/radio', + }, { component: markRaw( defineAsyncComponent(() => import('@/views/apps/PhoneApp.vue')), diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index a2589a7..e16ebfa 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -129,6 +129,62 @@ const defaultLocales: LocaleTree = { default: 'The phone request failed.', }, }, + radio: { + name: 'Radio', + disconnected: 'Not connected', + connectedTo: 'Connected - {frequency} MHz', + 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', + members: 'Currently connected ({count})', + noMembers: 'No participants', + history: 'Recently connected', + noHistory: 'No history', + badge: 'Service number', + badgePlaceholder: 'e.g. 231', + profileSaved: 'Radio profile saved', + displayName: 'Radio display name', + displayNamePlaceholder: 'Leave empty to use your character name', + displayNameDescription: + 'This name is shown to other participants in the radio and HUD.', + displayNameNotAllowed: + 'Your current job or grade is not allowed to change the radio display name.', + otherSettings: 'Other', + autoRejoin: 'Automatic rejoin', + autoRejoinDescription: + 'Reconnect after spawning or restarting the phone resource', + radioNotifications: 'Radio notifications', + notificationsDescription: 'Show joins and leaves on your current channel', + memberJoined: '{name} joined the radio', + memberLeft: '{name} left the radio', + unknownMember: 'Unknown', + tabs: { radio: 'Radio', settings: 'Settings' }, + errors: { + invalid_frequency: 'Enter a valid frequency.', + invalid_volume: 'Enter a valid volume.', + 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.', + 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.', + invalid_display_name: 'Enter a valid radio display name.', + request_failed: 'The radio request failed.', + default: 'The radio request failed.', + }, + }, calculator: { name: 'Calculator' }, snake: { name: 'Snake', diff --git a/frontend/src/stores/radio.ts b/frontend/src/stores/radio.ts new file mode 100644 index 0000000..6f1fe3d --- /dev/null +++ b/frontend/src/stores/radio.ts @@ -0,0 +1,142 @@ +import { reactive, ref } from 'vue' +import { defineStore } from 'pinia' + +import type { RadioData, RadioMember, RadioSettings } from '@/types/radio' +import { nuiCall } from '@/utils/nui' + +const defaults: RadioData = { + badge: '', + badgeEnabled: true, + badgeMaxLength: 8, + connected: false, + displayName: '', + displayNameAllowed: false, + displayNameEnabled: true, + displayNameMaxLength: 32, + frequency: 0, + frequencyMax: 999.9, + frequencyMin: 0.1, + frequencyStep: 0.1, + history: [], + members: [], + provider: null, + secondaryFrequency: 0, + secondarySupported: true, + settings: { autoRejoin: false, notifications: false }, + volume: 50, +} + +export const useRadioStore = defineStore('radio', () => { + const data = reactive(structuredClone(defaults)) + const error = ref('') + const isLoading = ref(false) + + function apply(next: Partial): void { + Object.assign(data, next) + } + + async function load(): Promise { + isLoading.value = true + error.value = '' + const response = await nuiCall('radio:get') + if (response.success && response.data) apply(response.data) + else error.value = response.error ?? 'request_failed' + isLoading.value = false + } + + async function connect( + frequency: number, + secondaryFrequency: number, + ): Promise { + isLoading.value = true + error.value = '' + const response = await nuiCall>('radio:connect', { + frequency, + secondaryFrequency, + }) + if (response.success && response.data) apply(response.data) + else error.value = response.error ?? 'request_failed' + isLoading.value = false + return response.success + } + + async function disconnect(): Promise { + error.value = '' + const response = await nuiCall('radio:disconnect') + if (!response.success) { + error.value = response.error ?? 'request_failed' + return + } + apply({ + connected: false, + frequency: 0, + members: [], + secondaryFrequency: 0, + }) + } + + async function setVolume(volume: number): Promise { + data.volume = volume + const response = await nuiCall<{ volume: number }>('radio:set-volume', { + volume, + }) + if (response.success && response.data) data.volume = response.data.volume + } + + async function saveSetting( + key: keyof RadioSettings, + value: boolean, + ): Promise { + const previous = data.settings[key] + data.settings[key] = value + const response = await nuiCall('radio:save-settings', { + key, + value, + }) + if (response.success && response.data) data.settings = response.data + else { + data.settings[key] = previous + error.value = response.error ?? 'request_failed' + } + } + + async function saveBadge(badge: string): Promise { + error.value = '' + const response = await nuiCall<{ badge: string }>('radio:save-badge', { + badge, + }) + if (response.success && response.data) data.badge = response.data.badge + else error.value = response.error ?? 'request_failed' + return response.success + } + + async function saveDisplayName(displayName: string): Promise { + error.value = '' + const response = await nuiCall<{ displayName: string }>( + 'radio:save-display-name', + { displayName }, + ) + if (response.success && response.data) + data.displayName = response.data.displayName + else error.value = response.error ?? 'request_failed' + return response.success + } + + function updateMembers(members: RadioMember[]): void { + data.members = members + } + + return { + connect, + data, + disconnect, + error, + isLoading, + load, + saveBadge, + saveDisplayName, + saveSetting, + setVolume, + updateMembers, + } +}) diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index fe27ca5..7e87d5d 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -10,6 +10,7 @@ export type PhoneAppId = | 'mail' | 'map' | 'notes' + | 'radio' | 'photos' | 'app-store' | 'settings' diff --git a/frontend/src/types/radio.ts b/frontend/src/types/radio.ts new file mode 100644 index 0000000..0c8167b --- /dev/null +++ b/frontend/src/types/radio.ts @@ -0,0 +1,39 @@ +export type RadioHistoryEntry = { + primary: number + secondary: number +} + +export type RadioMember = { + id: number + joinTime: number + name: string + rank: string + rankNumber: number +} + +export type RadioSettings = { + autoRejoin: boolean + notifications: boolean +} + +export type RadioData = { + badge: string + badgeEnabled: boolean + badgeMaxLength: number + connected: boolean + displayName: string + displayNameAllowed: boolean + displayNameEnabled: boolean + displayNameMaxLength: number + frequency: number + frequencyMax: number + frequencyMin: number + frequencyStep: number + history: RadioHistoryEntry[] + members: RadioMember[] + provider: string | null + secondaryFrequency: number + secondarySupported: boolean + settings: RadioSettings + volume: number +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 65872cd..2e1f5a7 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -67,6 +67,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< mail: { enabled: true, sounds: true }, map: { enabled: true, sounds: true }, notes: { enabled: true, sounds: true }, + radio: { enabled: true, sounds: true }, photos: { enabled: true, sounds: true }, settings: { enabled: true, sounds: true }, } diff --git a/frontend/src/views/apps/RadioApp.vue b/frontend/src/views/apps/RadioApp.vue new file mode 100644 index 0000000..66a4992 --- /dev/null +++ b/frontend/src/views/apps/RadioApp.vue @@ -0,0 +1,572 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 62822f0..eb99a83 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -20,6 +20,30 @@ function isoTime(offsetMilliseconds) { let authenticated = true let draft = null +const radioData = { + badge: '231', + badgeEnabled: true, + badgeMaxLength: 8, + connected: false, + displayName: 'Unit 21', + displayNameAllowed: true, + displayNameEnabled: true, + displayNameMaxLength: 32, + frequency: 0, + frequencyMax: 999.9, + frequencyMin: 0.1, + frequencyStep: 0.1, + history: [ + { primary: 120.5, secondary: 130.7 }, + { primary: 42.1, secondary: 0 }, + ], + members: [], + provider: 'yaca', + secondaryFrequency: 0, + secondarySupported: true, + settings: { autoRejoin: false, notifications: true }, + volume: 50, +} const accountDevices = [ { created_at: '2026-08-04 12:00:00', @@ -757,6 +781,64 @@ function counts() { app.post('/api/:endpoint', (request, response) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint + if (endpoint === 'radio:get') { + response.json({ success: true, data: radioData }) + return + } + if (endpoint === 'radio:connect') { + radioData.connected = true + radioData.frequency = Number(request.body.frequency) + radioData.secondaryFrequency = Number(request.body.secondaryFrequency) || 0 + radioData.members = [ + { + id: 12, + joinTime: 248, + name: 'Alex Morgan', + rank: 'Sergeant', + rankNumber: 3, + }, + { + id: 27, + joinTime: 42, + name: 'Jamie Rivera', + rank: 'Officer', + rankNumber: 1, + }, + ] + response.json({ success: true, data: radioData }) + return + } + if (endpoint === 'radio:disconnect') { + radioData.connected = false + radioData.frequency = 0 + radioData.secondaryFrequency = 0 + radioData.members = [] + response.json({ success: true }) + return + } + if (endpoint === 'radio:set-volume') { + radioData.volume = Math.max(0, Math.min(100, Number(request.body.volume))) + response.json({ success: true, data: { volume: radioData.volume } }) + return + } + if (endpoint === 'radio:save-settings') { + radioData.settings[request.body.key] = request.body.value === true + response.json({ success: true, data: radioData.settings }) + return + } + if (endpoint === 'radio:save-badge') { + radioData.badge = String(request.body.badge ?? '') + response.json({ success: true, data: { badge: radioData.badge } }) + return + } + if (endpoint === 'radio:save-display-name') { + radioData.displayName = String(request.body.displayName ?? '') + response.json({ + success: true, + data: { displayName: radioData.displayName }, + }) + return + } if (endpoint === 'weather:get') { response.json({ success: true, diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 6f0b75b..33599bd 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -34,6 +34,52 @@ Config.Calls = { RecentPageSize = 100, } +Config.Radio = { + VoiceProvider = "auto", -- auto, yaca, pma, saltychat + DefaultVolume = 50, + HistoryLimit = 8, + FrequencyMin = 0.1, + FrequencyMax = 999.9, + FrequencyDecimals = 1, + AllowSecondary = true, + Notifications = false, + AutoRejoin = false, + DisplayName = { + Enabled = true, + MaxLength = 32, + AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name. + police = 0, + sheriff = 0, + fib = 0, + army = 0, + ambulance = 0, + }, + }, + Hud = { + Enabled = true, + Resource = "sa_hudv2", + BadgeUpdateEvent = "sky_phone:radio:badgeUpdated", + DisplayNameUpdateEvent = "sky_phone:radio:displayNameUpdated", + }, + Badge = { + Enabled = true, + MaxLength = 8, + ForbiddenPatterns = { "88", "1488", "18", "14", "28", "198" }, + }, + LockedChannels = { + { + range = { 0.1, 100.0 }, + jobs = { + police = true, + sheriff = true, + fib = true, + army = true, + ambulance = true, + }, + }, + }, +} + Config.Mail = { Domain = "ifruit.com", LocalPartMinLength = 3, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 1ba811c..154a0d6 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -60,6 +60,33 @@ Locales["en"] = { default = "The phone request failed.", }, }, + radio = { + name = "Radio", disconnected = "Not connected", connectedTo = "Connected - {frequency} MHz", + 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", + 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", + displayName = "Radio display name", displayNamePlaceholder = "Leave empty to use your character name", + displayNameDescription = "This name is shown to other participants in the radio and HUD.", + displayNameNotAllowed = "Your current job or grade is not allowed to change the radio display name.", + autoRejoinDescription = "Reconnect after spawning or restarting the phone resource", + radioNotifications = "Radio notifications", notificationsDescription = "Show joins and leaves on your current channel", + memberJoined = "{name} joined the radio", memberLeft = "{name} left the radio", unknownMember = "Unknown", + tabs = { radio = "Radio", settings = "Settings" }, + errors = { + invalid_frequency = "Enter a valid frequency.", invalid_volume = "Enter a valid volume.", + 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.", + 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.", + invalid_display_name = "Enter a valid radio display name.", + request_failed = "The radio request failed.", default = "The radio request failed.", + }, + }, calculator = { name = "Calculator" }, snake = { name = "Snake", backToMenu = "Back to game menu", board = "Snake game board", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 1d30842..9365bb7 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -25,7 +25,9 @@ client_scripts { 'source/bridge/client/framework.lua', 'source/bridge/client/callbacks.lua', 'source/client/camera.lua', + 'source/bridge/client/radio.lua', 'source/client/main.lua', + 'source/client/radio.lua', } server_scripts { @@ -49,6 +51,7 @@ server_scripts { 'source/server/pages.lua', 'source/server/media.lua', 'source/server/calendar.lua', + 'source/server/radio.lua', } files { diff --git a/sky_phone/source/bridge/client/radio.lua b/sky_phone/source/bridge/client/radio.lua new file mode 100644 index 0000000..4c10413 --- /dev/null +++ b/sky_phone/source/bridge/client/radio.lua @@ -0,0 +1,120 @@ +local provider +local provider_resources = { + yaca = "yaca-voice", + pma = "pma-voice", + saltychat = "saltychat", +} +local provider_aliases = { + ["yaca-voice"] = "yaca", + ["pma-voice"] = "pma", + salty = "saltychat", +} + +local function resolve_provider() + if provider and GetResourceState(provider_resources[provider]) == "started" then + return provider + end + provider = nil + + local configured = Config.Radio.VoiceProvider + if configured ~= "auto" then + local selected = provider_aliases[configured] or configured + if provider_resources[selected] and GetResourceState(provider_resources[selected]) == "started" then + provider = selected + return provider + end + return nil + end + + local providers = { + { name = "yaca", resource = "yaca-voice" }, + { name = "pma", resource = "pma-voice" }, + { name = "saltychat", resource = "saltychat" }, + } + for _, candidate in ipairs(providers) do + if GetResourceState(candidate.resource) == "started" then + provider = candidate.name + return provider + end + end + + return nil +end + +function Bridge.Radio.GetProvider() + return resolve_provider() +end + +function Bridge.Radio.SupportsSecondary() + local selected = resolve_provider() + return Config.Radio.AllowSecondary and (selected == "yaca" or selected == "saltychat") +end + +function Bridge.Radio.Join(primary, secondary) + local selected = resolve_provider() + if selected == "yaca" then + local voice = exports["yaca-voice"] + if not voice:isRadioEnabled() then + voice:enableRadio(true) + Wait(100) + end + voice:setActiveRadioChannel(1) + voice:changeRadioFrequency(tostring(primary)) + if secondary > 0 and Bridge.Radio.SupportsSecondary() then + voice:setSecondaryRadioChannel(2) + voice:changeRadioFrequencyRaw(2, tostring(secondary)) + voice:muteRadioChannelRaw(2, false) + else + voice:changeRadioFrequencyRaw(2, "0") + voice:muteRadioChannelRaw(2, true) + end + voice:setActiveRadioChannel(1) + return true + end + + if selected == "pma" then + exports["pma-voice"]:setRadioChannel(primary) + return true + end + + if selected == "saltychat" then + exports.saltychat:SetRadioChannel(tostring(primary), true) + exports.saltychat:SetRadioChannel(secondary > 0 and tostring(secondary) or "", false) + return true + end + + Bridge.Debug("error", "[sky_phone] No supported radio voice provider is running.") + return false +end + +function Bridge.Radio.Leave() + local selected = resolve_provider() + if selected == "yaca" then + exports["yaca-voice"]:enableRadio(false) + elseif selected == "pma" then + exports["pma-voice"]:setRadioChannel(0) + elseif selected == "saltychat" then + exports.saltychat:SetRadioChannel("", true) + exports.saltychat:SetRadioChannel("", false) + end +end + +function Bridge.Radio.SetVolume(volume) + local selected = resolve_provider() + if selected == "yaca" then + exports["yaca-voice"]:changeRadioChannelVolumeRaw(1, volume / 100) + if Bridge.Radio.SupportsSecondary() then + exports["yaca-voice"]:changeRadioChannelVolumeRaw(2, volume / 100) + end + elseif selected == "pma" then + exports["pma-voice"]:setRadioVolume(volume) + elseif selected == "saltychat" then + exports.saltychat:SetRadioVolume(volume / 100) + end +end + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name == GetCurrentResourceName() then + Bridge.Radio.Leave() + end +end) diff --git a/sky_phone/source/bridge/server/frameworks/esx.lua b/sky_phone/source/bridge/server/frameworks/esx.lua index cfb534a..9665eb3 100644 --- a/sky_phone/source/bridge/server/frameworks/esx.lua +++ b/sky_phone/source/bridge/server/frameworks/esx.lua @@ -36,6 +36,20 @@ function Bridge.Framework.GetBirthdate(source) return player and (player.get("dateofbirth") or player.get("dob")) or nil end +function Bridge.Framework.GetJob(source) + local player = get_player(source) + local job = player and player.getJob() + if not job then + return { name = "", label = "", grade = 0, gradeLabel = "" } + end + return { + name = job.name or "", + label = job.label or "", + grade = tonumber(job.grade) or 0, + gradeLabel = job.grade_label or job.label or "", + } +end + function Bridge.Framework.RegisterUsableItem(item_name, callback) ESX.RegisterUsableItem(item_name, callback) return true diff --git a/sky_phone/source/bridge/server/frameworks/qb.lua b/sky_phone/source/bridge/server/frameworks/qb.lua index 71fa1e9..5f1783a 100644 --- a/sky_phone/source/bridge/server/frameworks/qb.lua +++ b/sky_phone/source/bridge/server/frameworks/qb.lua @@ -41,6 +41,18 @@ function Bridge.Framework.GetBirthdate(source) return character and character.birthdate or nil end +function Bridge.Framework.GetJob(source) + local player = get_player(source) + local job = player and player.PlayerData and player.PlayerData.job + local grade = job and job.grade + return { + name = job and job.name or "", + label = job and job.label or "", + grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0, + gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""), + } +end + function Bridge.Framework.RegisterUsableItem(item_name, callback) QBCore.Functions.CreateUseableItem(item_name, callback) return true diff --git a/sky_phone/source/bridge/server/frameworks/qbox.lua b/sky_phone/source/bridge/server/frameworks/qbox.lua index 6bde02f..0362339 100644 --- a/sky_phone/source/bridge/server/frameworks/qbox.lua +++ b/sky_phone/source/bridge/server/frameworks/qbox.lua @@ -39,6 +39,18 @@ function Bridge.Framework.GetBirthdate(source) return character and character.birthdate or nil end +function Bridge.Framework.GetJob(source) + local player = get_player(source) + local job = player and player.PlayerData and player.PlayerData.job + local grade = job and job.grade + return { + name = job and job.name or "", + label = job and job.label or "", + grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0, + gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""), + } +end + function Bridge.Framework.RegisterUsableItem(item_name, callback) exports.qbx_core:CreateUseableItem(item_name, callback) return true diff --git a/sky_phone/source/bridge/shared.lua b/sky_phone/source/bridge/shared.lua index d72fdcb..649e169 100644 --- a/sky_phone/source/bridge/shared.lua +++ b/sky_phone/source/bridge/shared.lua @@ -3,6 +3,7 @@ Bridge.Callbacks = Bridge.Callbacks or {} Bridge.Database = Bridge.Database or {} Bridge.Framework = Bridge.Framework or {} Bridge.Inventory = Bridge.Inventory or {} +Bridge.Radio = Bridge.Radio or {} local level_colours = { debug = "^5", diff --git a/sky_phone/source/client/radio.lua b/sky_phone/source/client/radio.lua new file mode 100644 index 0000000..6b4663b --- /dev/null +++ b/sky_phone/source/client/radio.lua @@ -0,0 +1,184 @@ +local current_volume = math.max(0, math.min(100, tonumber(Config.Radio.DefaultVolume) or 50)) +local current_primary = 0 +local current_secondary = 0 +local radio_settings = { + autoRejoin = Config.Radio.AutoRejoin, + notifications = Config.Radio.Notifications, +} +local auto_rejoin_pending = false + +local function request(name, data) + local result = Bridge.Callbacks.Trigger("sky_phone:radio:" .. name, data or {}) + if type(result) ~= "table" then + return { success = false, error = "request_failed" } + end + return result +end + +local function apply_server_state(data) + if type(data) ~= "table" then + return + end + current_primary = tonumber(data.frequency) or current_primary + current_secondary = tonumber(data.secondaryFrequency) or current_secondary + if type(data.settings) == "table" then + radio_settings.autoRejoin = data.settings.autoRejoin == true + radio_settings.notifications = data.settings.notifications == true + end +end + +local function join_radio(primary, secondary) + if not Bridge.Radio.SupportsSecondary() then + secondary = 0 + end + local approved = request("connect", { + frequency = primary, + secondaryFrequency = secondary, + }) + if not approved.success then + return approved + end + + local data = approved.data or {} + local approved_primary = tonumber(data.frequency) or 0 + local approved_secondary = tonumber(data.secondaryFrequency) or 0 + if not Bridge.Radio.Join(approved_primary, approved_secondary) then + request("disconnect") + return { success = false, error = "voice_unavailable" } + end + + current_primary = approved_primary + current_secondary = approved_secondary + Bridge.Radio.SetVolume(current_volume) + data.secondaryFrequency = approved_secondary + data.provider = Bridge.Radio.GetProvider() + data.secondarySupported = Bridge.Radio.SupportsSecondary() + return { success = true, data = data } +end + +local function leave_radio() + Bridge.Radio.Leave() + current_primary = 0 + current_secondary = 0 + return request("disconnect") +end + +RegisterNUICallback("radio:get", function(_, cb) + local result = request("get") + if result.success then + apply_server_state(result.data) + result.data.volume = current_volume + result.data.provider = Bridge.Radio.GetProvider() + result.data.secondarySupported = Bridge.Radio.SupportsSecondary() + end + cb(result) +end) + +RegisterNUICallback("radio:connect", function(data, cb) + cb(join_radio(data.frequency, data.secondaryFrequency)) +end) + +RegisterNUICallback("radio:disconnect", function(_, cb) + cb(leave_radio()) +end) + +RegisterNUICallback("radio:set-volume", function(data, cb) + local volume = tonumber(data.volume) + if not volume then + cb({ success = false, error = "invalid_volume" }) + return + end + current_volume = math.max(0, math.min(100, math.floor(volume + 0.5))) + Bridge.Radio.SetVolume(current_volume) + cb({ success = true, data = { volume = current_volume } }) +end) + +RegisterNUICallback("radio:save-settings", function(data, cb) + local result = request("save-settings", data) + if result.success then + apply_server_state({ settings = result.data }) + end + cb(result) +end) + +RegisterNUICallback("radio:save-badge", function(data, cb) + cb(request("save-badge", data)) +end) + +RegisterNUICallback("radio:save-display-name", function(data, cb) + cb(request("save-display-name", data)) +end) + +RegisterNetEvent("sky_phone:radio:members", function(data) + if tonumber(data.frequency) ~= current_primary then + return + end + SendNUIMessage({ type = "radio:updated", data = data }) +end) + +RegisterNetEvent("sky_phone:radio:notification", function(data) + if not radio_settings.notifications or current_primary <= 0 then + return + end + local locale = (Locales[Config.Bridge.Locale] or Locales.en).Nui.Apps.radio + local template = data.joined and locale.memberJoined or locale.memberLeft + SendNUIMessage({ + type = "notification:show", + data = { + appId = "radio", + title = locale.name, + text = template:gsub("{name}", tostring(data.playerName or locale.unknownMember)), + }, + }) +end) + +local function try_auto_rejoin() + if auto_rejoin_pending or current_primary > 0 then + return + end + auto_rejoin_pending = true + local result = request("get") + if result.success then + apply_server_state(result.data) + local data = result.data or {} + if radio_settings.autoRejoin and tonumber(data.savedFrequency) and tonumber(data.savedFrequency) > 0 then + local joined = join_radio(data.savedFrequency, data.savedSecondaryFrequency) + if not joined.success then + Bridge.Debug("warn", "[sky_phone] Radio auto-rejoin failed: %s", tostring(joined.error)) + end + end + end + auto_rejoin_pending = false +end + +AddEventHandler("playerSpawned", function() + SetTimeout(2000, try_auto_rejoin) +end) + +AddEventHandler("onResourceStart", function(resource_name) + if resource_name == GetCurrentResourceName() then + SetTimeout(5000, try_auto_rejoin) + end +end) + +RegisterNetEvent("yaca:external:setRadioFrequency", function(channel, frequency) + if Bridge.Radio.GetProvider() ~= "yaca" then + return + end + local channel_id = tonumber(channel) + local value = math.max(0, tonumber(frequency) or 0) + if channel_id == 1 then + current_primary = value + if value == 0 then + current_secondary = 0 + request("disconnect") + else + request("connect", { frequency = current_primary, secondaryFrequency = current_secondary }) + end + elseif channel_id == 2 then + current_secondary = value == current_primary and 0 or value + if current_primary > 0 then + request("connect", { frequency = current_primary, secondaryFrequency = current_secondary }) + end + end +end) diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index ab9817a..4a6e125 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +
diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 40d063d..0d1f8e4 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -629,6 +629,24 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_radio_profiles", + columns = { + { name = "identifier", type = "VARCHAR(80) NOT NULL" }, + { name = "history", type = "LONGTEXT NOT NULL" }, + { name = "settings", type = "LONGTEXT NOT NULL" }, + { name = "primary_frequency", type = "DOUBLE NOT NULL DEFAULT 0" }, + { name = "secondary_frequency", type = "DOUBLE NOT NULL DEFAULT 0" }, + { name = "badge", type = "VARCHAR(32) NOT NULL DEFAULT ''" }, + { name = "display_name", type = "VARCHAR(64) NOT NULL DEFAULT ''" }, + { + name = "updated_at", + type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", + }, + }, + primaryKey = "identifier", + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) diff --git a/sky_phone/source/server/radio.lua b/sky_phone/source/server/radio.lua new file mode 100644 index 0000000..f684d49 --- /dev/null +++ b/sky_phone/source/server/radio.lua @@ -0,0 +1,502 @@ +local profiles = {} +local channels = {} +local joined_at = {} +local last_requests = {} + +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" +end + +local function default_profile() + return { + history = {}, + settings = { + autoRejoin = Config.Radio.AutoRejoin, + notifications = Config.Radio.Notifications, + }, + primaryFrequency = 0, + secondaryFrequency = 0, + badge = "", + displayName = "", + } +end + +local function decode_table(value, fallback) + if type(value) ~= "string" or value == "" then + return fallback + end + local success, decoded = pcall(json.decode, value) + return success and type(decoded) == "table" and decoded or fallback +end + +local function normalize_frequency(value, allow_zero) + local frequency = tonumber(value) + if not frequency or frequency ~= frequency then + return nil + end + if allow_zero and frequency == 0 then + return 0 + end + if frequency < Config.Radio.FrequencyMin or frequency > Config.Radio.FrequencyMax then + return nil + end + local factor = 10 ^ Config.Radio.FrequencyDecimals + return math.floor(frequency * factor + 0.5) / factor +end + +local function can_set_display_name(source) + local config = Config.Radio.DisplayName + if type(config) ~= "table" or not config.Enabled then + return false + end + + local job = Bridge.Framework.GetJob(source) + local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil + return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade +end + +local function normalize_display_name(value) + local name = tostring(value or ""):gsub("%c", ""):gsub("%s+", " ") + name = name:match("^%s*(.-)%s*$") or "" + local length = utf8.len(name) + if not length then + return nil + end + + local maximum = math.max(1, math.min(tonumber(Config.Radio.DisplayName.MaxLength) or 32, 64)) + if length > maximum then + local next_character = utf8.offset(name, maximum + 1) + name = next_character and name:sub(1, next_character - 1) or name + end + return name +end + +local function has_channel_access(source, frequency) + local job = Bridge.Framework.GetJob(source) + for _, locked in ipairs(Config.Radio.LockedChannels or {}) do + local minimum = tonumber(locked.range and locked.range[1]) + local maximum = tonumber(locked.range and locked.range[2]) + if minimum and maximum and frequency >= minimum and frequency <= maximum then + return locked.jobs and locked.jobs[job.name] == true + end + end + return true +end + +local function sanitize_history(source, history) + local result = {} + local seen = {} + if type(history) ~= "table" then + return result + end + for index = 1, #history do + local entry = history[index] + if type(entry) == "table" then + local primary = normalize_frequency(entry.primary or entry.frequency, false) + local secondary = normalize_frequency(entry.secondary or entry.secondaryFrequency or 0, true) + if primary and secondary and secondary == primary then + secondary = 0 + end + if primary and secondary and has_channel_access(source, primary) + and (secondary == 0 or has_channel_access(source, secondary)) then + local key = ("%.3f|%.3f"):format(primary, secondary) + if not seen[key] then + result[#result + 1] = { primary = primary, secondary = secondary } + seen[key] = true + end + end + end + if #result >= Config.Radio.HistoryLimit then + break + end + end + return result +end + +local function load_profile(source) + local identifier = Bridge.Framework.GetIdentifier(source) + if not identifier then + return nil, nil + end + if profiles[identifier] then + return identifier, profiles[identifier] + end + + local profile = default_profile() + local rows = Bridge.Database.Query([[ + SELECT `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name` + FROM `sky_phone_radio_profiles` WHERE `identifier` = ? LIMIT 1 + ]], { identifier }) + local row = rows[1] + if row then + profile.history = sanitize_history(source, decode_table(row.history, {})) + local settings = decode_table(row.settings, {}) + profile.settings.autoRejoin = settings.autoRejoin == true + profile.settings.notifications = settings.notifications == true + profile.primaryFrequency = normalize_frequency(row.primary_frequency, true) or 0 + profile.secondaryFrequency = normalize_frequency(row.secondary_frequency, true) or 0 + profile.badge = tostring(row.badge or ""):sub(1, math.min(Config.Radio.Badge.MaxLength, 32)) + profile.displayName = normalize_display_name(row.display_name) or "" + end + profiles[identifier] = profile + return identifier, profile +end + +local function save_profile(identifier, profile) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_radio_profiles` + (`identifier`, `history`, `settings`, `primary_frequency`, `secondary_frequency`, `badge`, `display_name`) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `history` = VALUES(`history`), `settings` = VALUES(`settings`), + `primary_frequency` = VALUES(`primary_frequency`), + `secondary_frequency` = VALUES(`secondary_frequency`), `badge` = VALUES(`badge`), + `display_name` = VALUES(`display_name`) + ]], { + identifier, + json.encode(profile.history), + json.encode(profile.settings), + profile.primaryFrequency, + profile.secondaryFrequency, + profile.badge, + profile.displayName, + }) +end + +local function get_effective_display_name(source, profile) + if not can_set_display_name(source) then + return "" + end + if not profile then + local _, loaded_profile = load_profile(source) + profile = loaded_profile + end + return profile and profile.displayName or "" +end + +local function get_radio_member_name(source) + local display_name = get_effective_display_name(source) + return display_name ~= "" and display_name or GetPlayerName(source) or "Unknown" +end + +local function frequency_set(channel) + local result = {} + if channel and channel.primary and channel.primary > 0 then + result[channel.primary] = true + end + if channel and channel.secondary and channel.secondary > 0 then + result[channel.secondary] = true + end + return result +end + +local function get_members(frequency) + local members = {} + for player_source, channel in pairs(channels) do + if channel.primary == frequency or channel.secondary == frequency then + local job = Bridge.Framework.GetJob(player_source) + members[#members + 1] = { + id = player_source, + name = get_radio_member_name(player_source), + joinTime = os.time() - (joined_at[player_source] or os.time()), + rank = job.gradeLabel, + rankNumber = job.grade, + } + end + end + table.sort(members, function(left, right) + return left.name:lower() < right.name:lower() + end) + return members +end + +local function broadcast_frequency(frequency) + if not frequency or frequency <= 0 then + return + end + local members = get_members(frequency) + for _, member in ipairs(members) do + TriggerClientEvent("sky_phone:radio:members", member.id, { + frequency = frequency, + members = members, + }) + end +end + +local function notify_frequency(frequency, excluded_source, player_name, joined) + if not frequency or frequency <= 0 then + return + end + for player_source, channel in pairs(channels) do + if player_source ~= excluded_source + and (channel.primary == frequency or channel.secondary == frequency) then + TriggerClientEvent("sky_phone:radio:notification", player_source, { + joined = joined, + playerName = player_name, + }) + end + end +end + +local function remove_from_channels(source) + local previous = channels[source] + if not previous then + return + end + channels[source] = nil + joined_at[source] = nil + local name = get_radio_member_name(source) + for frequency in pairs(frequency_set(previous)) do + notify_frequency(frequency, source, name, false) + broadcast_frequency(frequency) + end +end + +local function rate_limited(source, action, milliseconds) + local now = GetGameTimer() + local key = ("%s:%s"):format(source, action) + if last_requests[key] and now - last_requests[key] < milliseconds then + return true + end + last_requests[key] = now + return false +end + +Bridge.Callbacks.Register("sky_phone:radio:get", function(source) + local _, profile = load_profile(source) + if not profile then + return { success = false, error = "player_unavailable" } + end + local channel = channels[source] + return { + success = true, + data = { + connected = channel ~= nil, + frequency = channel and channel.primary or 0, + secondaryFrequency = channel and channel.secondary or 0, + members = channel and get_members(channel.primary) or {}, + history = profile.history, + settings = profile.settings, + badge = profile.badge, + badgeEnabled = Config.Radio.Badge.Enabled, + badgeMaxLength = math.min(Config.Radio.Badge.MaxLength, 32), + displayName = profile.displayName, + displayNameAllowed = can_set_display_name(source), + displayNameEnabled = Config.Radio.DisplayName.Enabled, + displayNameMaxLength = math.min(Config.Radio.DisplayName.MaxLength, 64), + frequencyMin = Config.Radio.FrequencyMin, + frequencyMax = Config.Radio.FrequencyMax, + frequencyStep = 1 / (10 ^ Config.Radio.FrequencyDecimals), + savedFrequency = profile.primaryFrequency, + savedSecondaryFrequency = profile.secondaryFrequency, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:radio:connect", function(source, data) + if rate_limited(source, "connect", 500) then + return { success = false, error = "rate_limited" } + end + local primary = normalize_frequency(data.frequency, false) + local secondary = normalize_frequency(data.secondaryFrequency or 0, true) + if not primary or not secondary then + return { success = false, error = "invalid_frequency" } + end + if secondary == primary then + secondary = 0 + end + if not supports_secondary() then + secondary = 0 + end + if not has_channel_access(source, primary) then + return { success = false, error = "channel_locked" } + end + if secondary > 0 and not has_channel_access(source, secondary) then + return { success = false, error = "secondary_locked" } + end + + local identifier, profile = load_profile(source) + if not profile then + return { success = false, error = "player_unavailable" } + end + local previous = channels[source] + local previous_set = frequency_set(previous) + channels[source] = { primary = primary, secondary = secondary } + joined_at[source] = os.time() + + profile.primaryFrequency = primary + profile.secondaryFrequency = secondary + profile.history = sanitize_history(source, (function() + local history = { { primary = primary, secondary = secondary } } + for _, entry in ipairs(profile.history) do + history[#history + 1] = entry + end + return history + end)()) + save_profile(identifier, profile) + + local current_set = frequency_set(channels[source]) + local name = get_radio_member_name(source) + for frequency in pairs(previous_set) do + if not current_set[frequency] then + notify_frequency(frequency, source, name, false) + broadcast_frequency(frequency) + end + end + for frequency in pairs(current_set) do + if not previous_set[frequency] then + notify_frequency(frequency, source, name, true) + end + broadcast_frequency(frequency) + end + + return { + success = true, + data = { + connected = true, + frequency = primary, + secondaryFrequency = secondary, + members = get_members(primary), + history = profile.history, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:radio:disconnect", function(source) + remove_from_channels(source) + local identifier, profile = load_profile(source) + if profile then + profile.primaryFrequency = 0 + profile.secondaryFrequency = 0 + save_profile(identifier, profile) + end + return { success = 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 + return { success = false, error = "invalid_setting" } + end + local identifier, profile = load_profile(source) + if not profile then + return { success = false, error = "player_unavailable" } + end + profile.settings[key] = data.value == true + save_profile(identifier, profile) + return { success = true, data = profile.settings } +end) + +local function badge_forbidden(badge) + local digits = badge:gsub("%D", "") + for _, pattern in ipairs(Config.Radio.Badge.ForbiddenPatterns or {}) do + if digits:find(tostring(pattern), 1, true) then + return true + end + end + return false +end + +local function notify_hud(event_name, source, value) + local hud = Config.Radio.Hud + if type(hud) ~= "table" or not hud.Enabled then + return + end + if type(hud.Resource) ~= "string" or GetResourceState(hud.Resource) ~= "started" then + return + end + local event = hud[event_name] + if type(event) ~= "string" or event == "" then + return + end + TriggerClientEvent(event, -1, source, value) +end + +Bridge.Callbacks.Register("sky_phone:radio:save-badge", function(source, data) + if not Config.Radio.Badge.Enabled then + return { success = false, error = "badge_disabled" } + end + local badge = tostring(data.badge or ""):gsub("[^%w_%-]", ""):sub(1, math.min(Config.Radio.Badge.MaxLength, 32)) + if badge_forbidden(badge) then + return { success = false, error = "badge_forbidden" } + end + local identifier, profile = load_profile(source) + if not profile then + return { success = false, error = "player_unavailable" } + end + profile.badge = badge + save_profile(identifier, profile) + notify_hud("BadgeUpdateEvent", source, badge) + return { success = true, data = { badge = badge } } +end) + +Bridge.Callbacks.Register("sky_phone:radio:save-display-name", function(source, data) + if not Config.Radio.DisplayName.Enabled then + return { success = false, error = "display_name_disabled" } + end + if rate_limited(source, "save-display-name", 750) then + return { success = false, error = "rate_limited" } + end + if not can_set_display_name(source) then + return { success = false, error = "display_name_forbidden" } + end + + local display_name = normalize_display_name(data.displayName) + if display_name == nil then + return { success = false, error = "invalid_display_name" } + end + local identifier, profile = load_profile(source) + if not profile then + return { success = false, error = "player_unavailable" } + end + + profile.displayName = display_name + save_profile(identifier, profile) + for frequency in pairs(frequency_set(channels[source])) do + broadcast_frequency(frequency) + end + notify_hud("DisplayNameUpdateEvent", source, display_name) + return { success = true, data = { displayName = display_name } } +end) + +exports("GetPlayerBadge", function(source) + local _, profile = load_profile(tonumber(source)) + return profile and profile.badge or "" +end) + +exports("GetPlayerRadioDisplayName", function(source) + source = tonumber(source) + if not source then + return "" + end + return get_effective_display_name(source) +end) + +AddEventHandler("playerDropped", function() + local player_source = source + local identifier = Bridge.Framework.GetIdentifier(player_source) + remove_from_channels(player_source) + if identifier then + profiles[identifier] = nil + end + for key in pairs(last_requests) do + if key:sub(1, #tostring(player_source) + 1) == tostring(player_source) .. ":" then + last_requests[key] = nil + end + end +end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 0c385c0..6b695a3 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -194,3 +194,15 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` ( KEY `idx_sky_phone_calendar_reminders` (`reminded_at`, `starts_at`), FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_radio_profiles` ( + `identifier` VARCHAR(80) NOT NULL, + `history` LONGTEXT NOT NULL, + `settings` LONGTEXT NOT NULL, + `primary_frequency` DOUBLE NOT NULL DEFAULT 0, + `secondary_frequency` DOUBLE NOT NULL DEFAULT 0, + `badge` VARCHAR(32) NOT NULL DEFAULT '', + `display_name` VARCHAR(64) NOT NULL DEFAULT '', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From cb175a77bf414034b2cb91b49c662a258adb8876 Mon Sep 17 00:00:00 2001 From: DerEchteAlec Date: Thu, 6 Aug 2026 21:40:33 +0200 Subject: [PATCH 2/3] ENH - integrate phone-owned radio HUD --- README.md | 2 +- frontend/src/App.vue | 2 + frontend/src/components/RadioHud.vue | 324 +++++++++++++++++++++++++++ frontend/src/stores/phone.ts | 2 +- frontend/src/types/radio.ts | 18 ++ frontend/src/views/apps/RadioApp.vue | 8 +- sky_phone/config/config.lua | 10 +- sky_phone/config/locales/en.lua | 2 +- sky_phone/source/client/main.lua | 1 + sky_phone/source/client/radio.lua | 124 +++++++++- sky_phone/source/html/index.html | 4 +- sky_phone/source/server/radio.lua | 35 +-- 12 files changed, 489 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/RadioHud.vue diff --git a/README.md b/README.md index b87a179..bc2bd27 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Framework, inventory, callback, notification, and database integrations live und 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. -Configure frequency bounds and precision, restricted channel ranges and allowed jobs, history length, defaults, badge validation, radio display-name permissions, and the optional HUD integration 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. Setting `Config.Radio.Hud.Enabled = false` disables external HUD notifications while the phone-owned `GetPlayerBadge` and `GetPlayerRadioDisplayName` server exports remain available. +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. Radio profiles are stored in `sky_phone_radio_profiles`. Runtime migration creates the table automatically; fresh installations receive it through `sky_phone/sql/install.sql`. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index f6e415c..78c48ec 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -16,6 +16,7 @@ import PhoneLockScreen from '@/components/PhoneLockScreen.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' +import RadioHud from '@/components/RadioHud.vue' import SimPhonePicker, { type SimPhoneChoice, } from '@/components/SimPhonePicker.vue' @@ -417,6 +418,7 @@ onBeforeUnmount(() => {