ENH - integrate phone-owned radio HUD

This commit is contained in:
DerEchteAlec
2026-08-06 21:40:33 +02:00
parent fc859888c3
commit cb175a77bf
12 changed files with 489 additions and 43 deletions
+1 -1
View File
@@ -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`.
+2
View File
@@ -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(() => {
<template>
<PhoneMediaCapture />
<RadioHud />
<SimPhonePicker
v-if="simPicker"
:choices="simPicker.choices"
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { Headphones } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { RadioHudConfig, RadioHudMember } from '@/types/radio'
type RadioHudEntry = RadioHudMember & {
state: 'recent' | 'talking'
}
type RadioHudMessage = {
data?: Partial<RadioHudConfig> | { members?: RadioHudMember[] }
type?: string
}
const config = reactive<RadioHudConfig>({
enabled: false,
horizontal: 'right',
horizontalOffset: 2,
speakerPersistMilliseconds: 3000,
vertical: 'top',
verticalOffset: 30,
})
const entries = ref(new Map<number, RadioHudEntry>())
const removalTimers = new Map<number, number>()
const visibleEntries = computed(() =>
Array.from(entries.value.values()).sort((left, right) =>
left.name.localeCompare(right.name),
),
)
const positionStyle = computed(() => ({
'--radio-hud-horizontal-offset': `${config.horizontalOffset}vh`,
'--radio-hud-vertical-offset': `${config.verticalOffset}vh`,
}))
function clearRemovalTimer(id: number): void {
const timer = removalTimers.get(id)
if (timer !== undefined) window.clearTimeout(timer)
removalTimers.delete(id)
}
function setEntry(entry: RadioHudEntry): void {
const next = new Map(entries.value)
next.set(entry.id, entry)
entries.value = next
}
function removeEntry(id: number): void {
clearRemovalTimer(id)
const next = new Map(entries.value)
next.delete(id)
entries.value = next
}
function clearEntries(): void {
for (const timer of removalTimers.values()) window.clearTimeout(timer)
removalTimers.clear()
entries.value = new Map()
}
function scheduleRemoval(id: number): void {
clearRemovalTimer(id)
const duration = Math.max(
0,
Math.min(10_000, config.speakerPersistMilliseconds),
)
removalTimers.set(
id,
window.setTimeout(() => {
if (entries.value.get(id)?.state === 'recent') removeEntry(id)
}, duration),
)
}
function updateMembers(members: RadioHudMember[]): void {
const currentIds = new Set<number>()
for (const member of members) {
if (!Number.isInteger(member.id) || member.id <= 0) continue
currentIds.add(member.id)
const existing = entries.value.get(member.id)
const normalized = {
badge: String(member.badge ?? ''),
channel: member.channel === 2 ? 2 : 1,
id: member.id,
name: String(member.name || `ID ${member.id}`),
talking: member.talking === true,
} satisfies RadioHudMember
if (normalized.talking) {
clearRemovalTimer(member.id)
setEntry({ ...normalized, state: 'talking' })
} else if (existing?.state === 'talking') {
setEntry({ ...normalized, state: 'recent' })
scheduleRemoval(member.id)
} else if (existing) {
setEntry({ ...normalized, state: existing.state })
}
}
for (const id of entries.value.keys()) {
if (!currentIds.has(id)) removeEntry(id)
}
}
function updateConfig(value: Partial<RadioHudConfig>): void {
config.enabled = value.enabled === true
config.horizontal = value.horizontal === 'left' ? 'left' : 'right'
config.vertical = value.vertical === 'bottom' ? 'bottom' : 'top'
config.horizontalOffset = Math.max(
0,
Math.min(100, Number(value.horizontalOffset) || 0),
)
config.verticalOffset = Math.max(
0,
Math.min(100, Number(value.verticalOffset) || 0),
)
config.speakerPersistMilliseconds = Math.max(
0,
Math.min(10_000, Number(value.speakerPersistMilliseconds) || 0),
)
if (!config.enabled) clearEntries()
}
function onMessage(event: MessageEvent<RadioHudMessage>): void {
if (event.data?.type === 'radio:hud-config' && event.data.data) {
updateConfig(event.data.data as Partial<RadioHudConfig>)
} else if (event.data?.type === 'radio:hud-update' && event.data.data) {
const data = event.data.data as { members?: RadioHudMember[] }
updateMembers(Array.isArray(data.members) ? data.members : [])
}
}
onMounted(() => {
window.addEventListener('message', onMessage)
if (
import.meta.env.DEV &&
new URLSearchParams(window.location.search).has('radioHudPreview')
) {
updateConfig({
enabled: true,
horizontal: 'right',
horizontalOffset: 2,
speakerPersistMilliseconds: 3000,
vertical: 'top',
verticalOffset: 30,
})
updateMembers([
{
badge: '231',
channel: 1,
id: 21,
name: 'Unit 21',
talking: true,
},
{
badge: '12',
channel: 2,
id: 12,
name: 'Unit 12',
talking: true,
},
])
}
})
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
clearEntries()
})
</script>
<template>
<aside
v-if="config.enabled"
class="radio-hud"
:data-horizontal="config.horizontal"
:data-vertical="config.vertical"
:style="positionStyle"
aria-hidden="true"
>
<TransitionGroup
name="radio-hud-member"
tag="div"
class="radio-hud__members"
>
<div
v-for="entry in visibleEntries"
:key="entry.id"
class="radio-hud__member"
:class="[
`radio-hud__member--${entry.state}`,
{ 'radio-hud__member--secondary': entry.channel === 2 },
]"
>
<Headphones class="radio-hud__icon" aria-hidden="true" />
<span v-if="entry.badge" class="radio-hud__badge">
[{{ entry.badge }}]
</span>
<span class="radio-hud__name">{{ entry.name }}</span>
</div>
</TransitionGroup>
</aside>
</template>
<style scoped>
.radio-hud {
position: fixed;
z-index: 40;
display: flex;
pointer-events: none;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
.radio-hud[data-horizontal='left'] {
right: auto;
left: var(--radio-hud-horizontal-offset);
justify-content: flex-start;
}
.radio-hud[data-horizontal='right'] {
right: var(--radio-hud-horizontal-offset);
left: auto;
justify-content: flex-end;
}
.radio-hud[data-vertical='bottom'] {
top: auto;
bottom: var(--radio-hud-vertical-offset);
}
.radio-hud[data-vertical='top'] {
top: var(--radio-hud-vertical-offset);
bottom: auto;
}
.radio-hud__members {
display: flex;
flex-direction: column;
gap: 0.6vh;
align-items: flex-end;
}
.radio-hud[data-horizontal='left'] .radio-hud__members {
align-items: flex-start;
}
.radio-hud__member {
display: flex;
max-width: 32vw;
align-items: center;
gap: 0.7vh;
color: #4ade80;
font-size: clamp(12px, 1.25vh, 16px);
font-weight: 700;
line-height: 1;
filter: drop-shadow(0 2px 4px rgb(0 0 0 / 80%));
}
.radio-hud__member--secondary {
color: #facc15;
}
.radio-hud__member--recent {
color: rgb(255 255 255 / 90%);
}
.radio-hud__icon {
width: 1.8vh;
min-width: 14px;
height: 1.8vh;
min-height: 14px;
animation: radio-hud-pulse 0.8s ease-in-out infinite;
filter: drop-shadow(0 0 6px currentColor);
}
.radio-hud__member--recent .radio-hud__icon {
animation: none;
filter: none;
}
.radio-hud__badge {
opacity: 0.75;
white-space: nowrap;
letter-spacing: 0.03em;
}
.radio-hud__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.02em;
}
.radio-hud-member-enter-active,
.radio-hud-member-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.radio-hud-member-enter-from,
.radio-hud-member-leave-to {
opacity: 0;
transform: translateX(2vh);
}
.radio-hud[data-horizontal='left'] .radio-hud-member-enter-from,
.radio-hud[data-horizontal='left'] .radio-hud-member-leave-to {
transform: translateX(-2vh);
}
@keyframes radio-hud-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.55;
transform: scale(0.86);
}
}
</style>
+1 -1
View File
@@ -153,7 +153,7 @@ const defaultLocales: LocaleTree = {
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.',
'This name is shown to other participants and in the built-in radio overlay.',
displayNameNotAllowed:
'Your current job or grade is not allowed to change the radio display name.',
otherSettings: 'Other',
+18
View File
@@ -4,6 +4,7 @@ export type RadioHistoryEntry = {
}
export type RadioMember = {
badge: string
id: number
joinTime: number
name: string
@@ -11,6 +12,23 @@ export type RadioMember = {
rankNumber: number
}
export type RadioHudConfig = {
enabled: boolean
horizontal: 'left' | 'right'
horizontalOffset: number
speakerPersistMilliseconds: number
vertical: 'bottom' | 'top'
verticalOffset: number
}
export type RadioHudMember = {
badge: string
channel: 1 | 2
id: number
name: string
talking: boolean
}
export type RadioSettings = {
autoRejoin: boolean
notifications: boolean
+5 -3
View File
@@ -519,10 +519,12 @@ onBeforeUnmount(() => {
}
.radio-setting-hint {
color: var(--k-color-subtitle, #8e8e93);
font-size: 14px;
line-height: 1.4;
color: inherit;
font-size: 16px;
font-weight: 450;
line-height: 1.45;
margin: 0;
opacity: 0.82;
}
.radio-hint-block {
+7 -3
View File
@@ -57,9 +57,13 @@ Config.Radio = {
},
Hud = {
Enabled = true,
Resource = "sa_hudv2",
BadgeUpdateEvent = "sky_phone:radio:badgeUpdated",
DisplayNameUpdateEvent = "sky_phone:radio:displayNameUpdated",
SpeakerPersistMilliseconds = 3000,
Position = {
Horizontal = "right", -- left or right
Vertical = "top", -- top or bottom
HorizontalOffset = 2.0, -- vh
VerticalOffset = 30.0, -- vh
},
},
Badge = {
Enabled = true,
+1 -1
View File
@@ -69,7 +69,7 @@ Locales["en"] = {
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.",
displayNameDescription = "This name is shown to other participants and in the built-in radio overlay.",
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",
+1
View File
@@ -149,6 +149,7 @@ end
RegisterNUICallback("ui:ready", function(_, cb)
Bridge.Debug("debug", "[sky_phone] NUI reported ready.", { always = true })
TriggerEvent("sky_phone:client:nuiReady")
if open_requested and device_payload then
send_open_message()
end
+122 -2
View File
@@ -6,6 +6,87 @@ local radio_settings = {
notifications = Config.Radio.Notifications,
}
local auto_rejoin_pending = false
local hud_members = { [1] = {}, [2] = {} }
local hud_talking = {}
local function get_hud_config()
local hud = type(Config.Radio.Hud) == "table" and Config.Radio.Hud or {}
local position = type(hud.Position) == "table" and hud.Position or {}
return {
enabled = hud.Enabled == true,
horizontal = position.Horizontal == "left" and "left" or "right",
vertical = position.Vertical == "bottom" and "bottom" or "top",
horizontalOffset = math.max(0.0, math.min(100.0, tonumber(position.HorizontalOffset) or 2.0)),
verticalOffset = math.max(0.0, math.min(100.0, tonumber(position.VerticalOffset) or 30.0)),
speakerPersistMilliseconds = math.max(
0,
math.min(10000, math.floor(tonumber(hud.SpeakerPersistMilliseconds) or 3000))
),
}
end
local function send_hud_config()
SendNUIMessage({ type = "radio:hud-config", data = get_hud_config() })
end
local function send_hud_members()
local combined = {}
for channel_id = 1, 2 do
for player_id, member in pairs(hud_members[channel_id]) do
if not combined[player_id] then
local talking = hud_talking[player_id]
combined[player_id] = {
id = player_id,
name = member.name,
badge = member.badge,
talking = talking and talking.state or false,
channel = talking and talking.channel or channel_id,
}
end
end
end
local members = {}
for _, member in pairs(combined) do
members[#members + 1] = member
end
table.sort(members, function(left, right)
return left.name:lower() < right.name:lower()
end)
SendNUIMessage({ type = "radio:hud-update", data = { members = members } })
end
local function clear_hud_members()
hud_members = { [1] = {}, [2] = {} }
hud_talking = {}
send_hud_members()
end
local function set_hud_members(channel_id, members)
local channel_members = {}
if type(members) == "table" then
for _, member in ipairs(members) do
local player_id = tonumber(member.id)
if player_id then
channel_members[player_id] = {
name = tostring(member.name or ("ID " .. player_id)),
badge = tostring(member.badge or ""),
}
end
end
end
hud_members[channel_id] = channel_members
send_hud_members()
end
local function set_hud_talking(player_id, state, channel_id)
player_id = tonumber(player_id)
if not player_id then
return
end
hud_talking[player_id] = state and { state = true, channel = channel_id == 2 and 2 or 1 } or nil
send_hud_members()
end
local function request(name, data)
local result = Bridge.Callbacks.Trigger("sky_phone:radio:" .. name, data or {})
@@ -47,6 +128,9 @@ local function join_radio(primary, secondary)
return { success = false, error = "voice_unavailable" }
end
if current_primary ~= approved_primary or current_secondary ~= approved_secondary then
clear_hud_members()
end
current_primary = approved_primary
current_secondary = approved_secondary
Bridge.Radio.SetVolume(current_volume)
@@ -60,6 +144,7 @@ local function leave_radio()
Bridge.Radio.Leave()
current_primary = 0
current_secondary = 0
clear_hud_members()
return request("disconnect")
end
@@ -110,10 +195,40 @@ RegisterNUICallback("radio:save-display-name", function(data, cb)
end)
RegisterNetEvent("sky_phone:radio:members", function(data)
if tonumber(data.frequency) ~= current_primary then
local frequency = tonumber(data.frequency)
local channel_id = frequency == current_primary and 1 or frequency == current_secondary and 2 or nil
if not channel_id then
return
end
SendNUIMessage({ type = "radio:updated", data = data })
set_hud_members(channel_id, data.members)
if channel_id == 1 then
SendNUIMessage({ type = "radio:updated", data = data })
end
end)
RegisterNetEvent("yaca:external:isRadioReceiving", function(state, channel, player_id)
if Bridge.Radio.GetProvider() ~= "yaca" then
return
end
set_hud_talking(player_id, state == true, tonumber(channel) or 1)
end)
RegisterNetEvent("yaca:external:isRadioTalking", function(state, channel)
if Bridge.Radio.GetProvider() ~= "yaca" then
return
end
set_hud_talking(GetPlayerServerId(PlayerId()), state == true, tonumber(channel) or 1)
end)
RegisterNetEvent("yaca:external:isRadioEnabled", function(state)
if Bridge.Radio.GetProvider() == "yaca" and not state then
clear_hud_members()
end
end)
AddEventHandler("sky_phone:client:nuiReady", function()
send_hud_config()
send_hud_members()
end)
RegisterNetEvent("sky_phone:radio:notification", function(data)
@@ -168,17 +283,22 @@ RegisterNetEvent("yaca:external:setRadioFrequency", function(channel, frequency)
local channel_id = tonumber(channel)
local value = math.max(0, tonumber(frequency) or 0)
if channel_id == 1 then
hud_members[1] = {}
current_primary = value
if value == 0 then
current_secondary = 0
hud_members[2] = {}
hud_talking = {}
request("disconnect")
else
request("connect", { frequency = current_primary, secondaryFrequency = current_secondary })
end
elseif channel_id == 2 then
hud_members[2] = {}
current_secondary = value == current_primary and 0 or value
if current_primary > 0 then
request("connect", { frequency = current_primary, secondaryFrequency = current_secondary })
end
end
send_hud_members()
end)
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-CJHMfJNP.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-9OAzJ8iS.css">
<script type="module" crossorigin src="./assets/sky-index-DT-iq-Xa.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DtjEpgd_.css">
</head>
<body>
<div id="app"></div>
+5 -30
View File
@@ -210,9 +210,11 @@ local function get_members(frequency)
for player_source, channel in pairs(channels) do
if channel.primary == frequency or channel.secondary == frequency then
local job = Bridge.Framework.GetJob(player_source)
local _, profile = load_profile(player_source)
members[#members + 1] = {
id = player_source,
name = get_radio_member_name(player_source),
badge = Config.Radio.Badge.Enabled and profile and profile.badge or "",
joinTime = os.time() - (joined_at[player_source] or os.time()),
rank = job.gradeLabel,
rankNumber = job.grade,
@@ -412,21 +414,6 @@ local function badge_forbidden(badge)
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" }
@@ -441,7 +428,9 @@ Bridge.Callbacks.Register("sky_phone:radio:save-badge", function(source, data)
end
profile.badge = badge
save_profile(identifier, profile)
notify_hud("BadgeUpdateEvent", source, badge)
for frequency in pairs(frequency_set(channels[source])) do
broadcast_frequency(frequency)
end
return { success = true, data = { badge = badge } }
end)
@@ -470,23 +459,9 @@ Bridge.Callbacks.Register("sky_phone:radio:save-display-name", function(source,
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)