├── .gitignore ├── web ├── src │ ├── vite-env.d.ts │ ├── App.tsx │ ├── utils │ │ ├── misc.ts │ │ ├── debugData.ts │ │ └── fetchNui.ts │ ├── index.css │ ├── main.tsx │ ├── providers │ │ ├── LocaleProvider.tsx │ │ └── VisibilityProvider.tsx │ ├── hooks │ │ └── useNuiEvent.ts │ └── components │ │ ├── Radio.module.css │ │ └── Radio.tsx ├── assets │ └── img │ │ └── radio.png ├── tsconfig.node.json ├── .gitignore ├── index.html ├── vite.config.ts ├── build │ └── index.html ├── tsconfig.json ├── package.json ├── pnpm-lock.yaml └── yarn.lock ├── fxmanifest.lua ├── shared └── config.lua ├── README.md ├── server └── server.lua ├── client └── client.lua └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/assets/img/radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thomasdev18/ts_radio/HEAD/web/assets/img/radio.png -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Radio from "./components/Radio"; 2 | 3 | const App: React.FC = () => { 4 | return ; 5 | }; 6 | 7 | export default App; 8 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /web/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | // Will return whether the current environment is in a regular browser 2 | // and not CEF 3 | export const isEnvBrowser = (): boolean => !(window as any).invokeNative 4 | 5 | // Basic no operation function 6 | export const noop = () => {} -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | build 12 | package-lock.json 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 12 | React Boilerplate 13 | 14 | 15 |
16 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: './', 8 | build: { 9 | outDir: 'build', 10 | rollupOptions: { 11 | onwarn(warning, warn) { 12 | // Suppress "Module level directives cause errors when bundled" warnings 13 | if (warning.code === "MODULE_LEVEL_DIRECTIVE") { 14 | return; 15 | } 16 | warn(warning); 17 | }, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /web/build/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 12 | React Boilerplate 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /fxmanifest.lua: -------------------------------------------------------------------------------- 1 | fx_version 'cerulean' 2 | game 'gta5' 3 | 4 | description 'TS Scripts - Radio in React Typescript with Mantine V7' 5 | author 'TS Scripts - Thomas' 6 | version '1.0.0' 7 | 8 | ui_page 'web/build/index.html' 9 | 10 | shared_scripts { 11 | 'shared/**/*', 12 | '@ox_lib/init.lua', 13 | '@qbx_core/modules/lib.lua' 14 | } 15 | client_scripts { 16 | '@qbx_core/modules/playerdata.lua', 17 | 'client/**/*' 18 | } 19 | 20 | server_script 'server/**/' 21 | 22 | files { 23 | 'web/build/index.html', 24 | 'web/build/**/*', 25 | } 26 | 27 | dependency 'pma-voice' 28 | 29 | lua54 'yes' 30 | use_experimental_fxv2_oal 'yes' -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | /* @import url("https://use.typekit.net/wxh5ury.css"); */ 2 | @import url("https://use.typekit.net/qgr5ebd.css"); 3 | 4 | html { 5 | color-scheme: normal !important; 6 | } 7 | 8 | body { 9 | background: none !important; 10 | margin: 0; 11 | font-family: roboto-mono; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | height: 100vh; 15 | user-select: none; 16 | overflow: hidden !important; 17 | } 18 | 19 | p { 20 | margin: 0; 21 | } 22 | 23 | #root { 24 | height: 100%; 25 | } 26 | 27 | ::-webkit-scrollbar { 28 | background-color: transparent; 29 | padding: 0; 30 | margin: 0; 31 | width: 0; 32 | height: 0; 33 | } -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsscripts_radio", 3 | "homepage": "web/build", 4 | "private": true, 5 | "version": "0.1.0", 6 | "scripts": { 7 | "dev": "vite", 8 | "start:game": "vite build --watch", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.11.1", 14 | "@mantine/core": "^7.12.1", 15 | "@mantine/hooks": "^7.12.1", 16 | "@tabler/icons-react": "^3.12.0", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.2.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^18.0.25", 22 | "@types/react-dom": "^18.0.9", 23 | "@vitejs/plugin-react": "^2.2.0", 24 | "typescript": "^4.9.3", 25 | "vite": "^3.2.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/src/utils/debugData.ts: -------------------------------------------------------------------------------- 1 | import { isEnvBrowser } from './misc'; 2 | 3 | interface DebugEvent { 4 | action: string; 5 | data: T; 6 | } 7 | 8 | /** 9 | * Emulates dispatching an event using SendNuiMessage in the lua scripts. 10 | * This is used when developing in browser 11 | * 12 | * @param events - The event you want to cover 13 | * @param timer - How long until it should trigger (ms) 14 | */ 15 | export const debugData =

(events: DebugEvent

[], timer = 1000): void => { 16 | if (import.meta.env.MODE === 'development' && isEnvBrowser()) { 17 | for (const event of events) { 18 | setTimeout(() => { 19 | window.dispatchEvent( 20 | new MessageEvent('message', { 21 | data: { 22 | action: event.action, 23 | data: event.data, 24 | }, 25 | }), 26 | ); 27 | }, timer); 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /shared/config.lua: -------------------------------------------------------------------------------- 1 | Config = {} 2 | 3 | Config.MicClicks = true 4 | Config.whitelistSubChannels = true 5 | Config.maxFrequency = 999 6 | Config.leaveOnDeath = true 7 | Config.decimalPlaces = 2 8 | Config.radioMenuSound = true 9 | Config.scullyEmoteMenu = false 10 | 11 | Config.restrictedChannels = { 12 | [1] = { 13 | police = true, 14 | ambulance = true 15 | }, 16 | [2] = { 17 | police = true, 18 | ambulance = true 19 | }, 20 | [3] = { 21 | police = true, 22 | ambulance = true 23 | }, 24 | [4] = { 25 | police = true, 26 | ambulance = true 27 | }, 28 | [5] = { 29 | police = true, 30 | ambulance = true 31 | }, 32 | [6] = { 33 | police = true, 34 | ambulance = true 35 | }, 36 | [7] = { 37 | police = true, 38 | ambulance = true 39 | }, 40 | [8] = { 41 | police = true, 42 | ambulance = true 43 | }, 44 | [9] = { 45 | police = true, 46 | ambulance = true 47 | }, 48 | [10] = { 49 | police = true, 50 | ambulance = true 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /web/src/utils/fetchNui.ts: -------------------------------------------------------------------------------- 1 | import { isEnvBrowser } from "./misc"; 2 | 3 | /** 4 | * Simple wrapper around fetch API tailored for CEF/NUI use. This abstraction 5 | * can be extended to include AbortController if needed or if the response isn't 6 | * JSON. Tailor it to your needs. 7 | * 8 | * @param eventName - The endpoint eventname to target 9 | * @param data - Data you wish to send in the NUI Callback 10 | * @param mockData - Mock data to be returned if in the browser 11 | * 12 | * @return returnData - A promise for the data sent back by the NuiCallbacks CB argument 13 | */ 14 | 15 | export async function fetchNui(eventName: string, data?: any, mockData?: T): Promise { 16 | const options = { 17 | method: 'post', 18 | headers: { 19 | 'Content-Type': 'application/json; charset=UTF-8', 20 | }, 21 | body: JSON.stringify(data), 22 | }; 23 | 24 | if (isEnvBrowser() && mockData) return mockData; 25 | 26 | const resourceName = (window as any).GetParentResourceName ? (window as any).GetParentResourceName() : 'nui-frame-app'; 27 | 28 | const resp = await fetch(`https://${resourceName}/${eventName}`, options); 29 | 30 | const respFormatted = await resp.json() 31 | 32 | return respFormatted 33 | } 34 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import "@mantine/core/styles.css"; 5 | import App from "./App"; 6 | import { isEnvBrowser } from "./utils/misc"; 7 | import { VisibilityProvider } from "./providers/VisibilityProvider"; 8 | import { debugData } from "./utils/debugData"; 9 | import { MantineProvider } from "@mantine/core"; 10 | import LocaleProvider from "./providers/LocaleProvider"; 11 | 12 | debugData([ 13 | { 14 | action: "setVisible", 15 | data: "show-ui", 16 | }, 17 | ]); 18 | 19 | if (isEnvBrowser()) { 20 | const root = document.getElementById("root"); 21 | 22 | // https://i.imgur.com/iPTAdYV.png - Night time img 23 | root!.style.backgroundImage = 'url("https://i.imgur.com/3pzRj9n.png")'; 24 | root!.style.backgroundSize = "cover"; 25 | root!.style.backgroundRepeat = "no-repeat"; 26 | root!.style.backgroundPosition = "center"; 27 | } 28 | 29 | const root = document.getElementById("root"); 30 | 31 | ReactDOM.createRoot(root!).render( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | , 41 | ); 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TS SCRIPTS - RADIO 2 | 3 | ![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?style=flat) 4 | [![Discord](https://img.shields.io/discord/1272953408919310397?label=Discord)](https://discord.gg/UBnX997H6A) 5 | 6 | 7 | This resource is a React and TypeScript-based radio system for qbox using Mantine v7. It allows players to connect to radio channels, adjust volume, and manage settings through an clean and simplistic UI. The project uses the GPL v3 license. 8 | 9 | This resource is built to work for the qbox framework. You can make it work with any framework if you do some changes to the backend. 10 | 11 | ![image](https://github.com/user-attachments/assets/72754ac2-2827-4d8f-bddd-744fbf1e6ee1) 12 | 13 | ## Usage 14 | 15 | 1. Install the repository into your resources 16 | 2. Install dependencies using `pnpm i`. 17 | 3. If you want to edit the resource, run `pnpm dev` to start development mode. 18 | 4. If you do not need to edit, you can use the pre-built files that come with the repository. 19 | 20 | ### Development 21 | 22 | Use `pnpm dev` to run the development server and watch files during development. 23 | 24 | ### Production 25 | 26 | If you need to rebuild, run `pnpm build`. 27 | 28 | ## License 29 | 30 | This project is licensed under the GPL v3 license. Please ensure that you give credit and include this license in any distributions. 31 | -------------------------------------------------------------------------------- /web/src/providers/LocaleProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Context, createContext, useContext, useEffect, useState } from "react"; 2 | import { useNuiEvent } from "../hooks/useNuiEvent"; 3 | import { fetchNui } from "../utils/fetchNui"; 4 | import { debugData } from "../utils/debugData"; 5 | 6 | interface Locale { 7 | ui_playerMoney: string; 8 | ui_buttonText: string; 9 | ui_reset: string; 10 | } 11 | 12 | debugData( 13 | [ 14 | { 15 | action: "setLocale", 16 | data: { 17 | ui_playerMoney: "Player Money", 18 | ui_buttonText: "Click to Get Player Money", 19 | ui_reset: "reset", 20 | }, 21 | }, 22 | ], 23 | 2000, 24 | ); 25 | 26 | interface LocaleContextValue { 27 | locale: Locale; 28 | setLocale: (locales: Locale) => void; 29 | } 30 | 31 | const LocaleCtx = createContext(null); 32 | 33 | const LocaleProvider: React.FC<{ children: React.ReactNode }> = ({ 34 | children, 35 | }) => { 36 | const [locale, setLocale] = useState({ 37 | ui_playerMoney: "", 38 | ui_buttonText: "", 39 | ui_reset: "", 40 | }); 41 | 42 | useEffect(() => { 43 | fetchNui("loadLocale"); 44 | }, []); 45 | 46 | useNuiEvent("setLocale", async (data: Locale) => setLocale(data)); 47 | 48 | return ( 49 | 50 | {children} 51 | 52 | ); 53 | }; 54 | 55 | export default LocaleProvider; 56 | 57 | export const useLocales = () => 58 | useContext(LocaleCtx as Context); 59 | -------------------------------------------------------------------------------- /web/src/hooks/useNuiEvent.ts: -------------------------------------------------------------------------------- 1 | import {MutableRefObject, useEffect, useRef} from "react"; 2 | import {noop} from "../utils/misc"; 3 | 4 | interface NuiMessageData { 5 | action: string; 6 | data: T; 7 | } 8 | 9 | type NuiHandlerSignature = (data: T) => void; 10 | 11 | /** 12 | * A hook that manage events listeners for receiving data from the client scripts 13 | * @param action The specific `action` that should be listened for. 14 | * @param handler The callback function that will handle data relayed by this hook 15 | * 16 | * @example 17 | * useNuiEvent<{visibility: true, wasVisible: 'something'}>('setVisible', (data) => { 18 | * // whatever logic you want 19 | * }) 20 | * 21 | **/ 22 | 23 | export const useNuiEvent = ( 24 | action: string, 25 | handler: (data: T) => void 26 | ) => { 27 | const savedHandler: MutableRefObject> = useRef(noop); 28 | 29 | // Make sure we handle for a reactive handler 30 | useEffect(() => { 31 | savedHandler.current = handler; 32 | }, [handler]); 33 | 34 | useEffect(() => { 35 | const eventListener = (event: MessageEvent>) => { 36 | const { action: eventAction, data } = event.data; 37 | 38 | if (savedHandler.current) { 39 | if (eventAction === action) { 40 | savedHandler.current(data); 41 | } 42 | } 43 | }; 44 | 45 | window.addEventListener("message", eventListener); 46 | // Remove Event Listener on component cleanup 47 | return () => window.removeEventListener("message", eventListener); 48 | }, [action]); 49 | }; 50 | -------------------------------------------------------------------------------- /web/src/components/Radio.module.css: -------------------------------------------------------------------------------- 1 | .app_container { 2 | border-radius: 10px; 3 | position: fixed; 4 | top: 65%; 5 | left: 90%; 6 | transform: translate(-50%, -50%); 7 | width: 550px; 8 | height: 850px; 9 | overflow: hidden; 10 | justify-content: center; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | } 15 | 16 | .screen { 17 | position: relative; 18 | top: 17%; 19 | right: 1%; 20 | width: 138px; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | justify-content: center; 25 | border-radius: 5px; 26 | transition: background-color 0.3s ease; 27 | z-index: 1; 28 | } 29 | 30 | .radio_image { 31 | position: absolute; 32 | top: 0; 33 | left: 0; 34 | } 35 | 36 | .screenOn { 37 | background-color: lightgray; 38 | } 39 | 40 | .screenOff { 41 | background-color: darkgray; 42 | } 43 | 44 | .power_button { 45 | position: absolute; 46 | top: 40%; 47 | left: 35%; 48 | width: 30px; 49 | height: 25px; 50 | z-index: 3; 51 | cursor: pointer; 52 | } 53 | 54 | .info_card { 55 | width: 140px; 56 | height: 100%; 57 | margin-left: 2%; 58 | text-align: center; 59 | border-radius: 3px; 60 | z-index: 2; 61 | bottom: 4%; 62 | } 63 | 64 | .arrow_buttons_container { 65 | display: flex; 66 | justify-content: center; 67 | margin-top: 210px; 68 | margin-right: 5px; 69 | cursor: pointer; 70 | z-index: 3; 71 | } 72 | 73 | .arrow_button { 74 | margin: 0 10px; 75 | } 76 | 77 | .additional_buttons_container { 78 | z-index: 3; 79 | display: flex; 80 | justify-content: space-between; 81 | } 82 | -------------------------------------------------------------------------------- /web/src/providers/VisibilityProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Context, 3 | createContext, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from "react"; 8 | import { useNuiEvent } from "../hooks/useNuiEvent"; 9 | import { fetchNui } from "../utils/fetchNui"; 10 | import { isEnvBrowser } from "../utils/misc"; 11 | 12 | const VisibilityCtx = createContext(null); 13 | 14 | interface VisibilityProviderValue { 15 | setVisible: (visible: boolean) => void; 16 | visible: boolean; 17 | } 18 | 19 | // This should be mounted at the top level of your application, it is currently set to 20 | // apply a CSS visibility value. If this is non-performant, this should be customized. 21 | export const VisibilityProvider: React.FC<{ children: React.ReactNode }> = ({ 22 | children, 23 | }) => { 24 | const [visible, setVisible] = useState(false); 25 | 26 | useNuiEvent("setVisible", setVisible); 27 | 28 | // Handle pressing escape/backspace 29 | useEffect(() => { 30 | // Only attach listener when we are visible 31 | if (!visible) return; 32 | 33 | const keyHandler = (e: KeyboardEvent) => { 34 | if (["Escape"].includes(e.code)) { 35 | if (!isEnvBrowser()) fetchNui("hide-ui"); 36 | else setVisible(!visible); 37 | } 38 | }; 39 | 40 | window.addEventListener("keydown", keyHandler); 41 | 42 | return () => window.removeEventListener("keydown", keyHandler); 43 | }, [visible]); 44 | 45 | return ( 46 | 51 |

53 | {children} 54 |
55 | 56 | ); 57 | }; 58 | 59 | export const useVisibility = () => 60 | useContext( 61 | VisibilityCtx as Context, 62 | ); 63 | -------------------------------------------------------------------------------- /server/server.lua: -------------------------------------------------------------------------------- 1 | local restrictedChannels = Config.restrictedChannels 2 | local framework 3 | 4 | lib.addCommand('radio', { 5 | help = 'Opens the radio', 6 | }, function(source) 7 | TriggerClientEvent('openRadio', source, true) 8 | end) 9 | 10 | 11 | RegisterNetEvent('onResourceStart', function(resource) 12 | if resource ~= cache.resource then return end 13 | 14 | local esx = GetResourceState('es_extended') 15 | local qbx = GetResourceState('qbx_core') 16 | framework = esx == 'started' and 'ESX' or qbx == 'started' and 'QBX' or nil 17 | 18 | if not framework then error('Unable to detect framework - Compatible with ESX & QBX') end 19 | 20 | local ESX = exports.es_extended:getSharedObject() 21 | 22 | 23 | if framework == 'QBX' then 24 | exports.qbx_core:CreateUseableItem('radio', function(source) 25 | TriggerClientEvent('openRadio', source, true) 26 | end) 27 | else 28 | ESX.RegisterUsableItem('radio', function(source) 29 | TriggerClientEvent('openRadio', source, true) 30 | end) 31 | end 32 | 33 | local function getJobDuty(jobs, source) 34 | if framework == 'QBX' then 35 | local player = exports.qbx_core:GetPlayer(source) 36 | return jobs[player.PlayerData.job.name] and player.PlayerData.job.onduty 37 | else 38 | local player = ESX.GetPlayerFromId(source) 39 | 40 | return jobs[player.getJob().name] 41 | end 42 | end 43 | 44 | if not Config.whitelistSubChannels then 45 | for channel, jobs in pairs(restrictedChannels) do 46 | for i = 1, 99 do 47 | restrictedChannels[channel + (i / 100)] = jobs 48 | end 49 | end 50 | end 51 | 52 | for channel, jobs in pairs(restrictedChannels) do 53 | exports['pma-voice']:addChannelCheck(channel, function(source) 54 | return getJobDuty(jobs, source) 55 | end) 56 | end 57 | end) 58 | -------------------------------------------------------------------------------- /client/client.lua: -------------------------------------------------------------------------------- 1 | -- Show NUI 2 | local ShowNUI = function(arg) 3 | SetNuiFocus(arg, arg) 4 | SendNUIMessage({ action = 'setVisible', data = arg }) 5 | end 6 | 7 | local onRadio = false 8 | local inChannel = false 9 | local radioVolume = 50 10 | local radioChannel = 0 11 | local micClicks = true 12 | local framework 13 | 14 | -- Notify user 15 | local notifyUser = function(description, type) 16 | lib.notify({ description = description, type = type }) 17 | end 18 | 19 | -- Play audio 20 | local playAudio = function(audioName, audioRef) 21 | local source = cache.ped 22 | local soundId = GetSoundId() 23 | 24 | local sourceType = type(source) 25 | if sourceType == 'number' then 26 | PlaySoundFromEntity(soundId, audioName, source, audioRef, false, false) 27 | else 28 | PlaySoundFrontend(soundId, audioName, audioRef, true) 29 | end 30 | 31 | 32 | ReleaseSoundId(soundId) 33 | end 34 | 35 | -- Connect to a specific radio channel 36 | local connectToRadio = function(channel) 37 | if inChannel then 38 | exports['pma-voice']:setRadioChannel(0) 39 | playAudio('Start_Squelch', 'CB_RADIO_SFX') 40 | else 41 | inChannel = true 42 | exports['pma-voice']:setVoiceProperty('radioEnabled', true) 43 | playAudio('Start_Squelch', 'CB_RADIO_SFX') 44 | end 45 | exports['pma-voice']:setRadioChannel(channel) 46 | radioChannel = channel 47 | 48 | local channelMessage = channel % 1 > 0 and 'You are now on channel ' .. channel .. ' MHz' or 'You are now on channel ' .. channel .. '0 MHz' 49 | notifyUser(channelMessage, 'success') 50 | end 51 | 52 | -- Leave the current radio channel 53 | local leaveRadio = function() 54 | if not inChannel then return end 55 | 56 | if radioChannel == 0 then 57 | notifyUser('You are not connected to any channel', 'error') 58 | return 59 | end 60 | 61 | playAudio('End_Squelch', 'CB_RADIO_SFX') 62 | notifyUser('You have left the radio channel', 'error') 63 | 64 | radioChannel = 0 65 | inChannel = false 66 | exports['pma-voice']:setVoiceProperty('radioEnabled', false) 67 | exports['pma-voice']:setRadioChannel(0) 68 | end 69 | 70 | -- Handle the power button functionality 71 | local powerButton = function() 72 | onRadio = not onRadio 73 | 74 | if not onRadio then 75 | leaveRadio() 76 | end 77 | 78 | playAudio(onRadio and "On_High" or "Off_High", 'MP_RADIO_SFX') 79 | end 80 | 81 | -- Handle animations 82 | local Anim = function(emote) 83 | if Config.scullyEmoteMenu then 84 | exports.scully_emotemenu:playEmoteByCommand(emote) 85 | end 86 | end 87 | 88 | local roundMath = function(num, decimalPlaces) 89 | if not decimalPlaces then return math.floor(num + 0.5) end 90 | local power = 10 ^ decimalPlaces 91 | return math.floor((num * power) + 0.5) / power 92 | end 93 | 94 | -- NUI callback functions 95 | 96 | -- Hide UI and cancel emote 97 | local hideUI = function(_, cb) 98 | ShowNUI(false) 99 | if Config.scullyEmoteMenu then 100 | exports.scully_emotemenu:cancelEmote() 101 | end 102 | cb('ok') 103 | end 104 | 105 | -- Toggle radio power 106 | local toggleRadioPower = function(data, cb) 107 | onRadio = data.isOn 108 | if not onRadio then 109 | leaveRadio() 110 | end 111 | cb('ok') 112 | end 113 | 114 | 115 | -- Connect to radio callback 116 | local connectToRadioCallback = function(data, cb) 117 | if not onRadio then return cb('ok') end 118 | 119 | local rchannel = tonumber(data.channel) 120 | if not rchannel or type(rchannel) ~= "number" or rchannel > Config.maxFrequency or rchannel < 1 then 121 | notifyUser("This frequency is not available", 'error') 122 | return cb('ok') 123 | end 124 | 125 | rchannel = roundMath(rchannel, Config.decimalPlaces) 126 | 127 | if rchannel == radioChannel then 128 | notifyUser("You're already connected to this channel", 'error') 129 | return cb('ok') 130 | end 131 | 132 | local frequency = not Config.whitelistSubChannels and math.floor(rchannel) or rchannel 133 | if Config.restrictedChannels[frequency] and (not isRestricted(Config.restrictedChannels[frequency])) then 134 | notifyUser("You cannot connect to this channel", 'error') 135 | return cb('ok') 136 | end 137 | 138 | connectToRadio(rchannel) 139 | cb('ok') 140 | end 141 | 142 | -- Leave radio callback 143 | local leaveRadioCallback = function(_, cb) 144 | leaveRadio() 145 | cb('ok') 146 | end 147 | 148 | -- Power button callback 149 | local powerButtonCallback = function(_, cb) 150 | powerButton() 151 | cb(onRadio and 'on' or 'off') 152 | end 153 | 154 | -- Trigger notification callback 155 | local triggerNotification = function(_, cb) 156 | if onRadio then 157 | -- Logic to trigger a notification (using your server-side logic) 158 | cb('ok') 159 | else 160 | cb('not_on_radio') 161 | end 162 | end 163 | 164 | -- Increase volume 165 | local volumeUp = function(_, cb) 166 | if not onRadio then return cb('ok') end 167 | if radioVolume > 95 then 168 | notifyUser('Maximum volume reached', 'error') 169 | return 170 | end 171 | 172 | radioVolume = radioVolume + 5 173 | notifyUser('New volume level: ' .. radioVolume, 'success') 174 | exports['pma-voice']:setRadioVolume(radioVolume) 175 | cb('ok') 176 | end 177 | 178 | -- Decrease volume 179 | local volumeDown = function(_, cb) 180 | if not onRadio then return cb('ok') end 181 | if radioVolume < 10 then 182 | notifyUser('Minimum volume reached', 'error') 183 | return 184 | end 185 | 186 | radioVolume = radioVolume - 5 187 | notifyUser('New volume level: ' .. radioVolume, 'success') 188 | exports['pma-voice']:setRadioVolume(radioVolume) 189 | cb('ok') 190 | end 191 | 192 | -- Toggle mic clicks 193 | local toggleClicks = function(data, cb) 194 | if not onRadio then return cb('ok') end 195 | 196 | micClicks = data.micClicks or false 197 | exports['pma-voice']:setVoiceProperty("micClicks", micClicks) 198 | playAudio(micClicks and "On_High" or "Off_High", 'MP_RADIO_SFX') 199 | notifyUser('Mic clicks ' .. (micClicks and 'enabled' or 'disabled'), 'success') 200 | cb('ok') 201 | end 202 | 203 | -- Play sound 204 | local playSound = function(data, cb) 205 | if Config.radioMenuSound then 206 | local soundName = data.soundName 207 | if soundName == 'Next' then 208 | playAudio("On_High", 'MP_RADIO_SFX') 209 | end 210 | end 211 | cb('ok') 212 | end 213 | 214 | -- Register NUI callbacks 215 | RegisterNUICallback('hide-ui', hideUI) 216 | RegisterNUICallback('toggleRadioPower', toggleRadioPower) 217 | RegisterNUICallback('connectToRadio', connectToRadioCallback) 218 | RegisterNUICallback('leaveRadio', leaveRadioCallback) 219 | RegisterNUICallback('powerButton', powerButtonCallback) 220 | RegisterNUICallback('triggerNotification', triggerNotification) 221 | RegisterNUICallback('volumeUp', volumeUp) 222 | RegisterNUICallback('volumeDown', volumeDown) 223 | RegisterNUICallback('toggleClicks', toggleClicks) 224 | RegisterNUICallback('playSound', playSound) 225 | 226 | -- Event to open the radio interface 227 | RegisterNetEvent('openRadio') 228 | AddEventHandler('openRadio', function(arg) 229 | ShowNUI(arg) 230 | Anim('wt') 231 | end) 232 | 233 | -- Set mic clicks to default value on player load 234 | RegisterNetEvent('QBCore:Client:OnPlayerLoaded', function() 235 | exports['pma-voice']:setVoiceProperty("micClicks", true) 236 | end) 237 | 238 | RegisterNetEvent('esx:playerLoaded', function() 239 | exports['pma-voice']:setVoiceProperty("micClicks", true) 240 | end) 241 | 242 | -- Check radio item count 243 | AddEventHandler('ox_inventory:itemCount', function(itemName, totalCount) 244 | if itemName ~= 'radio' then return end 245 | if totalCount <= 0 and radioChannel ~= 0 then 246 | powerButton() 247 | end 248 | end) 249 | 250 | -- Handle player death state 251 | if Config.leaveOnDeath then 252 | AddStateBagChangeHandler('isDead', ('player:%s'):format(cache.serverId), function(_, _, value) 253 | if value and onRadio and radioChannel ~= 0 then 254 | leaveRadio() 255 | end 256 | end) 257 | 258 | AddEventHandler('esx:onPlayerDeath', function(data) 259 | if onRadio and radioChannel ~= 0 then 260 | leaveRadio() 261 | end 262 | end) 263 | end 264 | 265 | 266 | AddEventHandler('onResourceStart', function(resource) 267 | if resource ~= cache.resource then return end 268 | 269 | local esx = GetResourceState('es_extended') 270 | local qbx = GetResourceState('qbx_core') 271 | framework = esx == 'started' and 'ESX' or qbx == 'started' and 'QBX' or nil 272 | 273 | if not framework then error('Unable to detect framework - Compatible with ESX & QBX') end 274 | 275 | local ESX = exports.es_extended:getSharedObject() 276 | 277 | function isRestricted(restrictedChannels) 278 | if framework == 'QBX' then 279 | return restrictedChannels[QBX.PlayerData.job.name] or not QBX.PlayerData.job.onduty 280 | else 281 | ESX.PlayerData = ESX.GetPlayerData() 282 | return restrictedChannels[ESX.PlayerData.job.name] 283 | end 284 | end 285 | end) 286 | -------------------------------------------------------------------------------- /web/src/components/Radio.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Box, 4 | Image, 5 | Tooltip, 6 | TextInput, 7 | ActionIcon, 8 | Grid, 9 | Select, 10 | Text 11 | } from '@mantine/core'; 12 | import { useState, useEffect } from "react"; 13 | import { fetchNui } from "../utils/fetchNui"; 14 | import classes from "./Radio.module.css"; 15 | import { 16 | IconBuildingBroadcastTower, 17 | IconAdjustments, 18 | IconBellRingingFilled, 19 | IconArrowLeft, 20 | IconArrowRight 21 | } from '@tabler/icons-react'; 22 | import { useNuiEvent } from "../hooks/useNuiEvent"; 23 | 24 | export default function Radio() { 25 | const [isOn, setIsOn] = useState(false); 26 | const [selectedIcon, setSelectedIcon] = useState(0); 27 | const [radioNumber, setRadioNumber] = useState(""); 28 | const [currentChannel, setCurrentChannel] = useState(null); 29 | const [currentScreen, setCurrentScreen] = useState("main"); 30 | 31 | const togglePower = async () => { 32 | const newIsOn = !isOn; 33 | setIsOn(newIsOn); 34 | await fetchNui('powerButton'); 35 | if (!newIsOn) { 36 | setCurrentScreen("main"); 37 | } 38 | }; 39 | 40 | const connectToRadioChannel = async (channel: number) => { 41 | if (currentChannel === channel) { 42 | console.log('Already connected to this channel'); 43 | return; 44 | } 45 | 46 | try { 47 | await fetchNui('connectToRadio', { channel }); 48 | setCurrentChannel(channel); 49 | setCurrentScreen("main"); 50 | } catch (error) { 51 | console.error('Failed to connect to radio channel:', error); 52 | } 53 | }; 54 | 55 | const leaveRadioChannel = async () => { 56 | try { 57 | await fetchNui('leaveRadio'); 58 | setCurrentChannel(null); 59 | setRadioNumber(""); 60 | setCurrentScreen("main"); 61 | } catch (error) { 62 | console.error('Failed to leave radio channel:', error); 63 | } 64 | }; 65 | 66 | const triggerNotification = async () => { 67 | try { 68 | await fetchNui('triggerNotification'); 69 | console.log('Notification sent'); 70 | } catch (error) { 71 | console.error('Failed to trigger notification:', error); 72 | } 73 | }; 74 | 75 | const volumeUp = async () => { 76 | try { 77 | await fetchNui('volumeUp'); 78 | await playSound('Next'); 79 | } catch (error) { 80 | console.error('Failed to increase volume:', error); 81 | } 82 | }; 83 | 84 | const volumeDown = async () => { 85 | try { 86 | await fetchNui('volumeDown'); 87 | await playSound('Next'); 88 | } catch (error) { 89 | console.error('Failed to decrease volume:', error); 90 | } 91 | }; 92 | 93 | const setMicClicks = async (value: boolean) => { 94 | try { 95 | await fetchNui('toggleClicks', { micClicks: value }); 96 | await playSound('Next'); 97 | } catch (error) { 98 | console.error('Failed to toggle mic clicks:', error); 99 | } 100 | }; 101 | 102 | const icons = [ 103 | , 104 | , 105 | 106 | ]; 107 | 108 | const playSound = async (soundName: string) => { 109 | try { 110 | await fetchNui('playSound', { soundName }); 111 | } catch (error) { 112 | console.error('Failed to play sound:', error); 113 | } 114 | }; 115 | 116 | const handleLeftArrowClick = async () => { 117 | setSelectedIcon((prev) => (prev === 0 ? icons.length - 1 : prev - 1)); 118 | await playSound('Next'); 119 | }; 120 | 121 | const handleRightArrowClick = async () => { 122 | setSelectedIcon((prev) => (prev === icons.length - 1 ? 0 : prev + 1)); 123 | await playSound('Next'); 124 | }; 125 | 126 | const handleRadioNumberChange = (event: React.ChangeEvent) => { 127 | const value = event.target.value; 128 | 129 | // Regular expression to validate the input: 130 | // - Allow up to 3 digits before the decimal point 131 | // - Allow only one decimal point 132 | // - Allow up to 1 digit after the decimal point 133 | const regex = /^\d{0,3}(\.\d{0,2})?$/; 134 | 135 | if (regex.test(value)) { 136 | setRadioNumber(value); 137 | } 138 | }; 139 | 140 | const handleConfirm = async () => { 141 | if (selectedIcon === 0) { 142 | if (radioNumber.trim() !== "") { 143 | const channel = parseFloat(radioNumber); 144 | if (!isNaN(channel)) { 145 | await connectToRadioChannel(channel); 146 | } else { 147 | console.error("Invalid radio number"); 148 | } 149 | } 150 | } else if (selectedIcon === 1) { 151 | await triggerNotification(); 152 | } else if (selectedIcon === 2) { 153 | setCurrentScreen("settings"); 154 | } 155 | }; 156 | 157 | useEffect(() => { 158 | if (currentChannel !== null) { 159 | setRadioNumber(currentChannel.toString()); 160 | } 161 | }, [currentChannel]); 162 | 163 | return ( 164 | 165 | Radio Background 173 | 174 | 188 | {isOn && currentScreen === "main" && ( 189 | <> 190 | 202 | {icons.map((icon, index) => ( 203 | 214 | 219 | {icon} 220 | 221 | 222 | ))} 223 | 224 | 225 | {selectedIcon === 0 && ( 226 | <> 227 | } 229 | value={radioNumber} 230 | onChange={handleRadioNumberChange} 231 | style={{ marginBottom: '10px', width: '130px'}} 232 | /> 233 | 234 | )} 235 | 236 | 237 | )} 238 | 239 | {isOn && currentScreen === "settings" && ( 240 | 241 | Volume 242 | 243 | 244 | 245 | 246 | 247 |