Skip to content

Overview


📺 Televisions & SmartTVs are everywhere!#

Place a TV anywhere you like using the “item” feature, or create static TVs from the admin menu.


💼Job integration#

You can make it possible for people in the jobs of your choice to control the televisions!

You can specify one or more job definitions.


♻️ Sync & OneSync Compatibility#

Watch videos or live streams in sync with all other players at the same time.

If you disconnect from the server or encounter an issue, continue watching in sync with other players.


📺 Televisions#

A total of 147 different screens

  • 51 flat-screen TVs
  • 33 cinema, projector & large screens
  • 27 monitors & computers
  • 18 laptops & tablets
  • 9 CRT & retro TVs
  • 9 TV stands & AV units


A flexible and dynamic structure that allows you to add more screens!


🪄 User-Friendly Menu (UI - User Interface)#

Manage your video enjoyment with a compact and simple interface!

View and play your recently played videos/streams!

Save your favorite videos/streams and play them whenever you want!


⚙️ Performance#

NUI (CEF): The JavaScript Instance allocates an average of 100 ~ 200KB of RAM for a new video/stream/audio.

  • Note: All operations are performed entirely by another server, so it does not cause issues like NUI (CEF) crashes!

Client Side: Efforts have been made to optimize the system as much as possible. Unnecessary DUI elements are not rendered. The values are as follows:

  • Idle 0.00 ms
  • 0.03 ms when setting up a TV
  • 0.00 ms when near a TV. 0.03 ms if a video is playing

Server Side: Generally 0.00 ms, and 0.01 ms for any video loading process.


🚫 Blacklisted locations#

Televisions cannot be installed in these areas. No television-related activities are allowed.


🚩Supporting 7 Languages / You can add more#

Currently, ar, cz, ro, it, fr, de, and tr language support is available. You can add more from the locale files.


Config Files#

Config
lua
Config = {}
Config.Framework = {
    ["Framework"] = "auto", -- [[LEAVE AUTO]] auto, esx, qbcore or qbox
    ["ResourceName"] = "auto", -- [[LEAVE AUTO]] auto, es_extended or qb-core or your resource name. If you using qbx you should write qb-core
    ["SharedEvent"] = "" -- Event name for old cores.
}

Config.Language = "en" -- ar, cz, ro, it, fr, de, tr
Config.ShowDebug = false -- Show debug messages
Config.AdminMenu = {
    ["command"] = "tvadmin", -- Command to open the admin menu
    ["ace"] = "group.admin" -- ACE permission for the admin menu
}
Config.TvCommand = {
    ["command"] = "tv", -- This allows you to open the menu on your TV.
    ["suggestion"] = "This allows you to open the menu on the nearby TV" -- Command suggestion for the tv command
}
Config.DeleteTvWhenOwnerQuit = false --[[
    ⚠️⚠️⚠️
    Its recommended to keep it true, if you set it to false, when the owner of the TV leaves the game, 
    the TV will remain in the world and other players can use it. 
    
    If you set it to true, when the owner of the TV leaves the game,
     the TV will be deleted from the world.
]]
Config.SyncCheck = true --[[
    ⚠️⚠️⚠️
    This variable belongs to the code structure that checks whether the video is progressing as it should, in sync with other people.
    The reasons I use this are as follows:
        - When the game is minimized, the CPU freezes all game-related processes for a certain number of seconds or minutes. 
            > This suspends the game's processes. When the player returns to the game, they will hear the videos behind schedule by the duration of the freeze compared to other players. 
        - Players experiencing occasional freezes or lag will hear the video delayed by the duration of the game's lag. 
    This way, the remaining users will move back to the current second.
    
    
    💢💢💢
    If you set it to `false`, when these conditions occur, the user's video will lag behind the others (by the amount of time it freezes or stutters). 
    Therefore, it will not be synchronized, and when the video ends at the expected second, it will suddenly cut off. 
]]

Config.Medias = {
    -- Files must be placed in web/public/medias/ and served via fxmanifest.lua > files{}.
    -- type: "video" (mp4) or "audio" (mp3/ogg). Leave duration empty to auto-detect and cache it on first playback.
    ["sample_video"] = {
        ["file"] = "sample_video.mp4",
        ["type"] = "video",
        ["title"] = "Sample Video",
        ["author"] = "Server",
        ["thumbnail"] = nil, -- URL to the thumbnail image
        ["duration"] = 14,
    },
    ["sample_audio"] = {
        ["file"] = "sample_audio.mp3",
        ["type"] = "audio",
        ["title"] = "Sample Audio",
        ["author"] = "Server",
        ["thumbnail"] = nil, -- URL to the thumbnail image
        ["duration"] = 80,
    },
}

Config.UsableItems = {
    ["tv_crt_classic"] = {
        ["model"] = "prop_tv_01",
        ["screen"] = "tvscreen",
        ["label"] = "Classic CRT TV [30m]"
    },
    ["tv_crt_old"] = {
        ["model"] = "prop_tv_03",
        ["screen"] = "tvscreen",
        ["label"] = "Old CRT TV [20m]"
    },
    ["tv_crt_wood"] = {
        ["model"] = "prop_tv_06",
        ["screen"] = "tvscreen",
        ["label"] = "Wooden CRT TV [20m]"
    },
    ["tv_flat_small"] = {
        ["model"] = "prop_tv_flat_03b",
        ["screen"] = "tvscreen",
        ["label"] = "Small Flat TV [20m]"
    },
    ["tv_cinema"] = {
        ["model"] = "vw_prop_vw_cinema_tv_01",
        ["screen"] = "tvscreen",
        ["label"] = "Cinema TV [100m]"
    },
    ["tv_smash"] = {
        ["model"] = "des_tvsmash_start",
        ["screen"] = "tvscreen",
        ["label"] = "Smash TV [100m]"
    },
    ["tv_flat_overlay"] = {
        ["model"] = "prop_flatscreen_overlay",
        ["screen"] = "tvscreen",
        ["label"] = "Flat Overlay TV [30m]"
    },
    ["tv_laptop"] = {
        ["model"] = "prop_laptop_lester2",
        ["screen"] = "tvscreen",
        ["label"] = "Laptop TV [30m]"
    },
    ["tv_trevor"] = {
        ["model"] = "prop_trev_tv_01",
        ["screen"] = "tvscreen",
        ["label"] = "Trevor TV [60m]"
    },
    ["tv_vintage"] = {
        ["model"] = "prop_tv_02",
        ["screen"] = "tvscreen",
        ["label"] = "Vintage TV [20m]"
    },
    ["tv_crt_overlay"] = {
        ["model"] = "prop_tv_03_overlay",
        ["screen"] = "tvscreen",
        ["label"] = "CRT Overlay TV [20m]"
    },
    ["tv_flat_classic"] = {
        ["model"] = "prop_tv_flat_01",
        ["screen"] = "tvscreen",
        ["label"] = "Flat Classic TV [20m]"
    },
    ["tv_flat_screen"] = {
        ["model"] = "prop_tv_flat_01_screen",
        ["screen"] = "tvscreen",
        ["job"] = {"police", "ambulance"},
        ["label"] = "Flat Screen TV [20m]"
    },
    ["tv_flat_medium"] = {
        ["model"] = "prop_tv_flat_02",
        ["screen"] = "tvscreen",
        ["label"] = "Flat Medium TV [20m]"
    },
    ["tv_flat_medium2"] = {
        ["model"] = "prop_tv_flat_02b",
        ["screen"] = "tvscreen",
        ["label"] = "Flat Medium TV2 [30m]"
    },
    ["tv_flat_modern"] = {
        ["model"] = "prop_tv_flat_03",
        ["screen"] = "tvscreen",
        ["label"] = "Flat Modern TV [20m]"
    },
    ["tv_flat_premium"] = {
        ["model"] = "prop_tv_flat_michael",
        ["screen"] = "tvscreen",
        ["label"] = "Flat Premium TV [40m]"
    },
    ["tv_monitor_large"] = {
        ["model"] = "prop_monitor_w_large",
        ["screen"] = "tvscreen",
        ["label"] = "Large Monitor TV [40m]"
    },
    ["tv_cinema_big"] = {
        ["model"] = "v_ilev_cin_screen",
        ["screen"] = "cinscreen",
        ["label"] = "Cinema TV [30m]"
    },
}

Config.PlaceOptions = {
    ["timeout"] = 15 * 1000, -- Time to wait for the player to place the TV 
    ["maxDistance"] = 15.0, -- Max distance to place the TV from the player
    ["buttons"] = {
        ["accept"] = "E", -- Button to accept placing the TV
        ["cancel"] = "G", -- Button to cancel placing the TV
        ["snapGround"] = "Z" -- Button to snap the TV to the ground.
    },
    ["offset"] = vector4(1.25, 0.0, 0.0, 0.0), -- Offset from the player when placing the TV (x, y, z, heading)
    ["animations"] = {
        ["dict"] = "anim@heists@load_box", -- Animation dictionary for placing the TV
        ["clip"] = "load_box_1", -- Animation clip for placing the TV
        ["flag"] = 1, -- Animation flag for placing the TV
        ["duration"] = 2.5 * 1000, -- Duration of the animation in milliseconds
    },
}

Config.CameraSettings = {
    ["lookSensitivity"] = 5.0,   -- Mouse rotation speed (higher = camera turns faster)
    ["moveSpeed"] = 7.0,         -- WASD camera movement speed in meters per second
    ["zoomSpeed"] = 2.0,         -- FOV change per mouse-wheel tick
    ["fovOffset"] = 5.0,         -- The freecam starts at the player's gameplay FOV + this value
    ["minFov"] = 5.0,            -- Absolute lower clamp for the FOV (most you can ever zoom in)
    ["maxFov"] = 90.0,           -- Upper clamp for the FOV (most you can ever zoom out)
    ["fitMargin"] = 1.15,        -- Safety multiplier so the TV never touches the screen edges (1.0 = exact fit)
    ["transitionTime"] = 800,    -- Duration (ms) of the blend in/out between the gameplay camera and the freecam
}

Config.BlacklistedCoords = {
    --[[
        🚫🚫🚫
        You can add blacklist coordinates so that people cannot place tv's or screens there. 
        Example: { coords = vector3(x, y, z), radius = 10.0 }
    ]]

    { coords = vector3(455.5057, -994.2595, 26.9876), radius = 45.0 }, -- Mission Row Police Department [MRPD]
    { coords = vector3(320.7686, -590.9871, 45.0281), radius = 40.0 } -- Pillbox Hill Medical Center [PHMC]
}

Config.Debug = function(...)
    if Config.ShowDebug then
        print(...)
    end
end

Config.Notification = function(title, text, type, time)
    title = title or "Television"
    time = time or 5000

    return lib.notify({
        title = title,
        type = type,
        duration = time,
        description = text,
        iconAnimation = "beatFade",
        position = "top"
    })
end
Server Config
lua
ConfigSv = {}
ConfigSv.Webhook = "" -- Webhook URL for logging when a song starts playing. Set this to your Discord webhook URL to enable logging.
ConfigSv.Inventorys = {
    -- [[ 🟢 Inventory detections, to work automatically compatible with some inventories. 🟢 ]]
    ["qb_inventory"] = GetResourceState("qb-inventory"):find("start") and true or false,
    ["qs_inventory"] = GetResourceState("qs-inventory"):find("start") and true or false,
    ["ox_inventory"] = GetResourceState("ox_inventory"):find("start") and true or false,
    ["export"] = nil
}

---------------------------------------------------------------------------------------------------------------
---This function is used to access player names.
---@enum [parent=#ConfigSv] GetPlayerName
---@param source
---@return #string
ConfigSv.GetPlayerName = function(source)
    if Config.Framework.Framework == "esx" then
        local Player = wFramework.Framework.GetPlayerFromId(source)
        return Player ~= nil and ('%s - %s'):format(Player.get('firstName'), Player.get('lastName')) or "Unknown Player"
    elseif Config.Framework.Framework:find("qb") then
        local Player = wFramework.Framework.Functions.GetPlayer(source)
        return Player ~= nil and ('%s - %s'):format(Player.PlayerData.charinfo.firstname, Player.PlayerData.charinfo.lastname) or "Unknown Player"
    end
end
---------------------------------------------------------------------------------------------------------------

---------------------------------------------------------------------------------------------------------------
---Function that takes care of player items giving
---@enum [parent=#ConfigSv] AddItem
---@param source
---@param item
---@param amount
---@return #boolean
ConfigSv.AddItem = function(src, item, amount)
    local p = promise:new()

    if Config.Framework.Framework == "esx" then
        local xPlayer = wFramework.Framework.GetPlayerFromId(src)
        if xPlayer ~= nil then
            xPlayer.addInventoryItem(item, amount)
            p:resolve(true)
        else
            Config.Debug(("[^1ERROR - ADDITEM^0] Could not find player with source %s - ConfigSv.AddItem"):format(src))
            p:resolve(false)
        end
    elseif Config.Framework.Framework:find("qb") then
        local Player = wFramework.Framework.Functions.GetPlayer(src)
        if Player ~= nil then
            if (ConfigSv.Inventorys.export == nil or ConfigSv.Inventorys.export:AddItem(src, item, amount) == nil) then
                Player.Functions.AddItem(item, amount)
            end

            p:resolve(true)
        else
            Config.Debug(("[^1ERROR - ADDITEM^0] Could not find player with source %s - ConfigSv.AddItem"):format(src))
            p:resolve(false)
        end
    else
        Config.Debug(("[^1ERROR - ADDITEM^0] No compatible inventory found for adding item to player with source %s - ConfigSv.AddItem"):format(src))
        p:resolve(false)
    end

    return Citizen.Await(p)
end
---------------------------------------------------------------------------------------------------------------

---------------------------------------------------------------------------------------------------------------
---Function that takes care of player items removing
---@enum [parent=#ConfigSv] RemoveItem
---@param source
---@param item
---@param amount
---@return #boolean
ConfigSv.RemoveItem = function(src, item, amount)
    local p = promise:new()
    local Player = Config.Framework.Framework == "esx" and wFramework.Framework.GetPlayerFromId(src) or ((Config.Framework.Framework:find("qb")) and wFramework.Framework.Functions.GetPlayer(src) or nil)
    if not Player then
        p:resolve(false)
        Config.Debug(("[^1ERROR - REMOVEITEM^0] Could not find player with source %s - ConfigSv.RemoveItem"):format(src))
        return Citizen.Await(p)
    end

    if ConfigSv.Inventorys.export ~= nil then
        p:resolve(ConfigSv.Inventorys.export:RemoveItem(src, item, amount))
        return Citizen.Await(p)
    end

    if Config.Framework.Framework:find("qb") then
        p:resolve(Player.Functions.RemoveItem(item, amount))
    else
        if Player.getInventoryItem(item).count >= amount then
            Player.removeInventoryItem(item, amount)
            p:resolve(true)
        else
            p:resolve(false)
        end
    end

    return Citizen.Await(p)
end
---------------------------------------------------------------------------------------------------------------

---Function to send a webhook when a song starts playing. It sends the owner's source, video ID, net ID, and metadata (which includes title, length, author, etc.). You can customize the data sent to the webhook as needed.
---@enum [parent=#ConfigSv] SendWebhook
---@param owner number The source of the player who started the song.
---@param videoId string The ID of the YouTube video being played.
---@param netId number The network ID of the television entity.
---@param metaData table A table containing metadata about the song, such as title, length, author, thumbnail, etc.
---@param platform string The platform of the video/stream (e.g., "youtube", "twitch").
---@param url string The URL of the video/stream.
---@return void
ConfigSv.SendWebhook = function(sender, coords, url, platform, metadata)
    if ConfigSv.Webhook == "" then return end
    --[[
        🟢🟢🟢
        You can set up a webhook to log when a song starts playing. 
        The function receives the owner's source, video ID, net ID, and metadata (which includes title, length, author, etc.). 
        You can customize the data sent to the webhook as needed.
    ]]
    
    local ts = os.time()
    local time = os.date('!%Y-%m-%dT%H:%M:%S.000Z', ts)
    local name = sender > 0 and GetPlayerName(sender) or "Server DefaultTV"

    local embed = {
        {
            ["description"] = 'The player played a video/stream',
            ["color"] = 16705372,
            ["fields"] = {
                {["name"] = "📃 Player", ["value"] = ('**%s [%s]**'):format(name, sender)},
                {["name"] = "🎥 Video/Stream", ["value"] = ('**[%s-%s](%s)**'):format(metadata.author, metadata.title, CompleteURL(platform, url))},
                {["name"] = "📍 Coords", ["value"] = ("vec3(%.2f, %.2f, %.2f)"):format(coords.x, coords.y, coords.z)},
            },
            ["author"] = {
                ["name"] = "Television",
                ["url"] = "https://0resmon.tebex.io/package/fivem-speaker-boombox-carplay-script",
                ["icon_url"] = "https://cdn-icons-png.flaticon.com/32/4406/4406124.png"
            },
            ["timestamp"] =  time,
            ["thumbnail"] =  {
                ["url"] =  metadata.thumbnail
            }
        }
    }
    PerformHttpRequest(ConfigSv.Webhook, function(err, text, headers) end, 'POST', json.encode({username = "wais-tv", avatar_url = "https://cdn.discordapp.com/avatars/225154669817626626/12042ba04def3b74fdb3f6492853ee37.png?size=1024", embeds = embed}), { ['Content-Type'] = 'application/json' })
end
---------------------------------------------------------------------------------------------------------------

CreateThread(function()
    ConfigSv.Inventorys.export = ConfigSv.Inventorys.qs_inventory and exports['qs-inventory'] or ConfigSv.Inventorys.ox_inventory and exports.ox_inventory or nil or ConfigSv.Inventorys.qb_inventory and exports['qb-inventory'] or nil
    if not ConfigSv.Inventorys.export then
        Config.Debug("No compatible inventory found, please set the export method manually in config_sv.lua")
    end
end)