CLN - remove weather camera views

Remove the remote weather-camera experience from the phone UI, NUI test server, and weather state.\n\nDelete the associated client and server camera handlers, configuration, locale copy, and manifest entries while retaining the core weather forecast app.
This commit is contained in:
Type
2026-08-13 11:43:32 +02:00
parent b3db702a2e
commit c1f2f820c1
12 changed files with 2 additions and 485 deletions
-113
View File
@@ -3515,119 +3515,6 @@ button {
letter-spacing: 0.04em;
text-transform: uppercase;
}
.weather-camera-card {
padding-bottom: 12px;
}
.weather-camera-preview {
position: relative;
min-height: 146px;
overflow: hidden;
background:
radial-gradient(circle at 75% 20%, #bfe8ff9c, transparent 28%),
linear-gradient(160deg, #82b4d5 0%, #496783 52%, #27364c 100%);
}
.weather-camera-preview--rain,
.weather-camera-preview--thunder {
background:
radial-gradient(circle at 70% 18%, #9cb4c380, transparent 26%),
linear-gradient(160deg, #667d8d 0%, #34495f 50%, #1a2839 100%);
}
.weather-camera-preview--clear,
.weather-camera-preview--sunny {
background:
radial-gradient(circle at 76% 19%, #fff4c9 0 5%, #ffd78370 6%, transparent 29%),
linear-gradient(160deg, #72b7df 0%, #4d86b2 55%, #35506e 100%);
}
.weather-camera-preview::after {
position: absolute;
inset: -50% 0 auto;
height: 3px;
background: #ffffff38;
box-shadow: 0 0 12px #fff8;
content: '';
animation: weather-camera-scan 4s linear infinite;
}
.weather-camera-skyline {
position: absolute;
right: -5%;
bottom: 0;
left: -5%;
height: 51%;
background:
linear-gradient(90deg, transparent 0 5%, #142333 5% 17%, transparent 17% 21%, #192b3c 21% 34%, transparent 34% 42%, #11202f 42% 60%, transparent 60% 65%, #182a3b 65% 76%, transparent 76% 81%, #102131 81% 95%, transparent 95%),
linear-gradient(0deg, #111c2a 0 18%, transparent 18%);
clip-path: polygon(0 43%, 9% 43%, 9% 22%, 18% 22%, 18% 57%, 27% 57%, 27% 10%, 37% 10%, 37% 48%, 48% 48%, 48% 30%, 61% 30%, 61% 62%, 71% 62%, 71% 16%, 83% 16%, 83% 49%, 100% 49%, 100% 100%, 0 100%);
opacity: 0.85;
}
.weather-camera-meta,
.weather-camera-copy {
position: absolute;
z-index: 1;
right: 12px;
left: 12px;
display: flex;
justify-content: space-between;
text-shadow: 0 2px 8px #000c;
}
.weather-camera-meta {
top: 11px;
align-items: center;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.weather-camera-live {
display: flex;
align-items: center;
gap: 5px;
}
.weather-camera-live i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ff4e5b;
box-shadow: 0 0 0 4px #ff4e5b2e;
animation: weather-camera-pulse 1.6s ease-in-out infinite;
}
.weather-camera-copy {
bottom: 10px;
align-items: flex-end;
flex-direction: column;
}
.weather-camera-copy strong {
font-size: 16px;
}
.weather-camera-copy span {
color: #ffffffc4;
font-size: 10px;
}
.weather-camera-action {
padding: 11px 12px 0;
}
.weather-camera-action button span {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.weather-camera-action p {
margin: 8px 3px 0;
color: #ffd2d2;
font-size: 10px;
text-align: center;
}
.weather-camera-unavailable {
padding: 16px;
color: #ffffffb3;
font-size: 12px;
text-align: center;
}
@keyframes weather-camera-scan {
to { transform: translateY(290px); }
}
@keyframes weather-camera-pulse {
50% { opacity: 0.45; }
}
.weather-hourly {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
-22
View File
@@ -2338,28 +2338,6 @@ const defaultLocales: LocaleTree = {
unavailable: 'Weather is unavailable',
stale: 'Showing the last available weather update.',
tryAgain: 'Try Again',
webcam: {
title: 'Weather Cameras',
live: 'LIVE',
view: 'View Live Camera',
subtitle: 'A live look at current conditions',
unavailable: 'No weather cameras are available.',
locations: {
legion_square: 'Legion Square',
sandy_shores: 'Sandy Shores',
cayo_airstrip: 'Cayo Perico Airstrip',
},
errors: {
camera_disabled: 'Weather cameras are currently disabled.',
camera_not_found: 'This weather camera is no longer available.',
camera_requires_safe_position:
'Stand still and exit your vehicle before opening a camera.',
camera_active: 'A weather camera is already open.',
rate_limited: 'Too many camera requests. Try again shortly.',
request_failed: 'The weather camera could not be opened.',
not_authenticated: 'Unlock the phone before opening a camera.',
},
},
regions: {
los_santos: 'Los Santos',
blaine_county: 'Blaine County',
-20
View File
@@ -61,24 +61,4 @@ describe('weather store', () => {
expect(weather.error).toBe('offline')
})
it('loads the server-approved camera list and opens a camera by id', async () => {
vi.mocked(nuiCall)
.mockResolvedValueOnce({
data: { cameras: [{ id: 'legion_square', region: 'los_santos' }] },
success: true,
})
.mockResolvedValueOnce({ success: true })
const weather = useWeatherStore()
await weather.loadCameras()
const opened = await weather.openCamera('legion_square')
expect(weather.cameras).toEqual([
{ id: 'legion_square', region: 'los_santos' },
])
expect(nuiCall).toHaveBeenLastCalledWith('weather:camera-open', {
id: 'legion_square',
})
expect(opened).toBe(true)
})
})
+1 -39
View File
@@ -1,10 +1,6 @@
import { defineStore } from 'pinia'
import type {
RawWeatherSnapshot,
WeatherCamera,
WeatherForecast,
} from '@/types/weather'
import type { RawWeatherSnapshot, WeatherForecast } from '@/types/weather'
import { nuiCall } from '@/utils/nui'
import { buildWeatherForecast } from '@/utils/weather'
@@ -13,45 +9,12 @@ const REFRESH_INTERVAL = 30_000
export const useWeatherStore = defineStore('weather', {
state: () => ({
error: null as string | null,
cameraError: null as string | null,
cameras: [] as WeatherCamera[],
camerasLoaded: false,
forecast: null as WeatherForecast | null,
intervalId: undefined as number | undefined,
isLoading: false,
isOpeningCamera: false,
lastFetchedAt: 0,
}),
actions: {
async loadCameras(force = false): Promise<void> {
if (this.camerasLoaded && !force) return
const response = await nuiCall<{ cameras: WeatherCamera[] }>(
'weather:cameras',
)
this.camerasLoaded = true
if (!response.success || !Array.isArray(response.data?.cameras)) {
this.cameraError = response.error ?? 'request_failed'
return
}
this.cameras = response.data.cameras.filter(
(camera) =>
typeof camera?.id === 'string' &&
['los_santos', 'blaine_county', 'cayo_perico'].includes(
camera.region,
),
)
this.cameraError = null
},
async openCamera(id: string): Promise<boolean> {
if (this.isOpeningCamera) return false
this.isOpeningCamera = true
const response = await nuiCall('weather:camera-open', { id })
this.isOpeningCamera = false
this.cameraError = response.success
? null
: (response.error ?? 'request_failed')
return response.success
},
async refresh(force = false): Promise<void> {
if (this.isLoading) return
if (!force && Date.now() - this.lastFetchedAt < REFRESH_INTERVAL) return
@@ -68,7 +31,6 @@ export const useWeatherStore = defineStore('weather', {
},
start(): void {
void this.refresh(true)
void this.loadCameras()
if (this.intervalId !== undefined) return
this.intervalId = window.setInterval(() => void this.refresh(true), REFRESH_INTERVAL)
},
-5
View File
@@ -10,11 +10,6 @@ export type WeatherConditionId =
export type WeatherRegionId = 'los_santos' | 'blaine_county' | 'cayo_perico'
export type WeatherCamera = {
id: string
region: WeatherRegionId
}
export type WeatherClock = {
day: number
hour: number
+1 -52
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { kButton, kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
import { kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
import {
CloudSun,
Droplets,
@@ -8,7 +8,6 @@ import {
RefreshCw,
ThermometerSun,
Umbrella,
Video,
Wind,
} from 'lucide-vue-next'
import { computed } from 'vue'
@@ -21,11 +20,6 @@ import type { WeatherConditionId } from '@/types/weather'
const phone = usePhoneStore()
const weather = useWeatherStore()
const forecast = computed(() => weather.forecast)
const selectedCamera = computed(
() =>
weather.cameras.find((camera) => camera.region === forecast.value?.region) ??
weather.cameras[0],
)
const rainy = computed(
() =>
forecast.value?.condition === 'rain' ||
@@ -71,11 +65,6 @@ function formatHour(timestamp: number, index: number): string {
}).format(timestamp)
}
function cameraErrorLabel(): string {
return phone.t(
`Apps.weather.webcam.errors.${weather.cameraError ?? 'request_failed'}`,
)
}
</script>
<template>
@@ -168,46 +157,6 @@ function cameraErrorLabel(): string {
</k-card>
</section>
<k-card
v-if="selectedCamera"
:colors="cardColors"
:content-wrap="false"
class="weather-panel weather-camera-card"
>
<div class="weather-camera-preview" :class="`weather-camera-preview--${forecast.condition}`">
<div class="weather-camera-skyline" aria-hidden="true"></div>
<div class="weather-camera-meta">
<span class="weather-camera-live"><i></i>{{ phone.t('Apps.weather.webcam.live') }}</span>
<span>{{ phone.t(`Apps.weather.webcam.locations.${selectedCamera.id}`) }}</span>
</div>
<div class="weather-camera-copy">
<strong>{{ phone.t('Apps.weather.webcam.title') }}</strong>
<span>{{ phone.t('Apps.weather.webcam.subtitle') }}</span>
</div>
</div>
<div class="weather-camera-action">
<k-button
large
rounded
:disabled="weather.isOpeningCamera"
@click="weather.openCamera(selectedCamera.id)"
>
<Video :size="17" />
{{ phone.t('Apps.weather.webcam.view') }}
</k-button>
<p v-if="weather.cameraError">{{ cameraErrorLabel() }}</p>
</div>
</k-card>
<k-card
v-else-if="weather.camerasLoaded"
:colors="cardColors"
:content-wrap="false"
class="weather-panel weather-camera-unavailable"
>
{{ phone.t('Apps.weather.webcam.unavailable') }}
</k-card>
<k-card
:colors="cardColors"
:content-wrap="false"
-22
View File
@@ -4779,28 +4779,6 @@ app.post('/api/:endpoint', (request, response) => {
})
return
}
if (endpoint === 'weather:cameras') {
response.json({
success: true,
data: {
cameras: [
{ id: 'legion_square', region: 'los_santos' },
{ id: 'sandy_shores', region: 'blaine_county' },
{ id: 'cayo_airstrip', region: 'cayo_perico' },
],
},
})
return
}
if (endpoint === 'weather:camera-open') {
const ids = ['legion_square', 'sandy_shores', 'cayo_airstrip']
response.json(
ids.includes(request.body?.id)
? { success: true }
: { success: false, error: 'camera_not_found' },
)
return
}
if (endpoint === 'map:getPlayerCoords') {
response.json({
success: true,
-12
View File
@@ -1,6 +1,5 @@
Locales["en"] = {
CommandDescription = "Open your phone.",
WeatherCameraExit = "Weather camera | Press BACKSPACE to return",
FlipTokCommand = {
usage = "Usage: /{command} <@handle> [on|off]",
noPermission = "You do not have permission to manage FlipTok verification.",
@@ -948,17 +947,6 @@ Locales["en"] = {
feelsLike = "Feels Like", wind = "Wind", humidity = "Humidity", rain = "Precipitation",
hourly = "Next Hours", unavailable = "Weather is unavailable",
stale = "Showing the last available weather update.", tryAgain = "Try Again",
webcam = {
title = "Weather Cameras", live = "LIVE", view = "View Live Camera",
subtitle = "A live look at current conditions", unavailable = "No weather cameras are available.",
locations = { legion_square = "Legion Square", sandy_shores = "Sandy Shores", cayo_airstrip = "Cayo Perico Airstrip" },
errors = {
camera_disabled = "Weather cameras are currently disabled.", camera_not_found = "This weather camera is no longer available.",
camera_requires_safe_position = "Stand still and exit your vehicle before opening a camera.",
camera_active = "A weather camera is already open.", rate_limited = "Too many camera requests. Try again shortly.",
request_failed = "The weather camera could not be opened.", not_authenticated = "Unlock the phone before opening a camera.",
},
},
regions = { los_santos = "Los Santos", blaine_county = "Blaine County", cayo_perico = "Cayo Perico" },
conditions = {
sunny = "Sunny", clear = "Clear", partly_cloudy = "Partly Cloudy", cloudy = "Cloudy",
-32
View File
@@ -1,32 +0,0 @@
Config.WeatherCameras = {
Enabled = true,
RequestsPerMinute = 10,
SessionSeconds = 30,
UseStreamingFocus = true,
Cameras = {
{
Id = "legion_square",
Region = "los_santos",
Coords = { x = -236.72, y = -1001.42, z = 45.20 },
Rotation = { x = -16.0, y = 0.0, z = -28.0 },
FieldOfView = 52.0,
},
{
Id = "sandy_shores",
Region = "blaine_county",
Coords = { x = 1712.64, y = 3654.86, z = 47.25 },
Rotation = { x = -13.0, y = 0.0, z = 142.0 },
FieldOfView = 55.0,
},
{
Id = "cayo_airstrip",
Region = "cayo_perico",
Coords = { x = 4437.38, y = -4471.62, z = 24.42 },
Rotation = { x = -10.0, y = 0.0, z = 45.0 },
FieldOfView = 58.0,
},
},
}
-- Remote cameras never move the player or alter spectator/entity state. Streaming focus is
-- local and is cleared when the camera closes. Disable it if an anti-cheat policy forbids it.
-3
View File
@@ -32,7 +32,6 @@ client_scripts {
'source/client/skyride.lua',
'source/client/housing.lua',
'source/client/crewlink.lua',
'source/client/weather_camera.lua',
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/main.lua',
@@ -42,7 +41,6 @@ client_scripts {
server_scripts {
'@oxmysql/lib/MySQL.lua',
'config/config.lua',
'config/weather_cameras.lua',
'config/media.lua',
'config/music.lua',
'config/locales/*.lua',
@@ -57,7 +55,6 @@ server_scripts {
'source/bridge/server/inventory/*.lua',
'source/server/db_migrate.lua',
'source/server/phone.lua',
'source/server/weather.lua',
'source/server/sim.lua',
'source/server/calls.lua',
'source/server/media.lua',
-103
View File
@@ -1,103 +0,0 @@
local active_camera = nil
local focus_active = false
local camera_session = 0
local transition_ms = 350
local function stop_weather_camera(resume_phone)
camera_session = camera_session + 1
if active_camera and DoesCamExist(active_camera) then
RenderScriptCams(false, true, transition_ms, true, false)
DestroyCam(active_camera, false)
end
active_camera = nil
if focus_active then ClearFocus() end
focus_active = false
if resume_phone then
SetNuiFocus(true, true)
SendNUIMessage({ type = "app:resume" })
end
end
local function valid_vector(value)
return type(value) == "table"
and type(value.x) == "number" and value.x == value.x
and type(value.y) == "number" and value.y == value.y
and type(value.z) == "number" and value.z == value.z
end
local function start_weather_camera(camera)
if type(camera) ~= "table" or not valid_vector(camera.coords) or not valid_vector(camera.rotation) then
return false
end
local fov = tonumber(camera.fieldOfView)
local duration = tonumber(camera.sessionSeconds)
if not fov or fov < 20.0 or fov > 90.0 or not duration then return false end
camera_session = camera_session + 1
local session = camera_session
SetNuiFocus(false, false)
SendNUIMessage({ type = "app:suspend" })
active_camera = CreateCamWithParams(
"DEFAULT_SCRIPTED_CAMERA",
camera.coords.x, camera.coords.y, camera.coords.z,
camera.rotation.x, camera.rotation.y, camera.rotation.z,
fov, true, 2
)
if not active_camera or not DoesCamExist(active_camera) then
stop_weather_camera(true)
return false
end
if camera.useStreamingFocus == true then
SetFocusPosAndVel(camera.coords.x, camera.coords.y, camera.coords.z, 0.0, 0.0, 0.0)
focus_active = true
end
SetCamActive(active_camera, true)
RenderScriptCams(true, true, transition_ms, true, false)
CreateThread(function()
local expires_at = GetGameTimer() + math.floor(duration * 1000)
while active_camera and session == camera_session do
Wait(0)
DisableAllControlActions(0)
EnableControlAction(0, 177, true)
BeginTextCommandDisplayHelp("STRING")
AddTextComponentSubstringPlayerName(locale.WeatherCameraExit or "Weather camera | BACKSPACE to return")
EndTextCommandDisplayHelp(0, false, true, -1)
if IsControlJustReleased(0, 177) or IsEntityDead(PlayerPedId()) or GetGameTimer() >= expires_at then
stop_weather_camera(true)
break
end
end
end)
return true
end
RegisterNUICallback("weather:cameras", function(_, cb)
local response = Bridge.Callbacks.Trigger("sky_phone:weather:cameras", {})
cb(response or { success = false, error = "request_failed" })
end)
RegisterNUICallback("weather:camera-open", function(data, cb)
if active_camera then cb({ success = false, error = "camera_active" }) return end
local id = type(data) == "table" and data.id or nil
if type(id) ~= "string" or not id:match("^[a-z0-9_]+$") then
cb({ success = false, error = "camera_not_found" })
return
end
local ped = PlayerPedId()
if IsEntityDead(ped) or IsPedInAnyVehicle(ped, false) or GetEntitySpeed(ped) > 1.0 then
cb({ success = false, error = "camera_requires_safe_position" })
return
end
local response = Bridge.Callbacks.Trigger("sky_phone:weather:camera-open", { id = id })
if not response or response.success ~= true or not start_weather_camera(response.data) then
cb(response or { success = false, error = "request_failed" })
return
end
cb({ success = true })
end)
AddEventHandler("sky_phone:nuiClosed", function() stop_weather_camera(false) end)
AddEventHandler("onResourceStop", function(resource)
if resource == GetCurrentResourceName() then stop_weather_camera(false) end
end)
-62
View File
@@ -1,62 +0,0 @@
local camera_config = Config.WeatherCameras or {}
local cameras_by_id = {}
local camera_summaries = {}
local function finite_number(value)
return type(value) == "number" and value == value and value > -math.huge and value < math.huge
end
for _, camera in ipairs(camera_config.Cameras or {}) do
local id = type(camera.Id) == "string" and camera.Id or ""
local coords = camera.Coords or {}
local rotation = camera.Rotation or {}
if id:match("^[a-z0-9_]+$")
and type(camera.Region) == "string"
and finite_number(coords.x) and finite_number(coords.y) and finite_number(coords.z)
and finite_number(rotation.x) and finite_number(rotation.y) and finite_number(rotation.z)
then
cameras_by_id[id] = camera
camera_summaries[#camera_summaries + 1] = { id = id, region = camera.Region }
else
print(("[sky_phone] Ignoring invalid weather camera configuration: %s"):format(tostring(camera.Id)))
end
end
local function authorize(source)
local session = SkyPhone.RequireSession(source)
if not session then return false, "not_authenticated" end
if camera_config.Enabled == false then return false, "camera_disabled" end
local limit = math.max(1, math.floor(tonumber(camera_config.RequestsPerMinute) or 10))
if not SkyPhone.AllowOperation(source, "weather_camera", limit, 60) then
return false, "rate_limited"
end
return true
end
Bridge.Callbacks.Register("sky_phone:weather:cameras", function(source)
local allowed, error_code = authorize(source)
if not allowed then return { success = false, error = error_code } end
return { success = true, data = { cameras = camera_summaries } }
end)
Bridge.Callbacks.Register("sky_phone:weather:camera-open", function(source, data)
local allowed, error_code = authorize(source)
if not allowed then return { success = false, error = error_code } end
local id = type(data) == "table" and data.id or nil
local camera = type(id) == "string" and cameras_by_id[id] or nil
if not camera then return { success = false, error = "camera_not_found" } end
local fov = math.max(20.0, math.min(90.0, tonumber(camera.FieldOfView) or 50.0))
local duration = math.max(10, math.min(120, math.floor(tonumber(camera_config.SessionSeconds) or 30)))
return {
success = true,
data = {
id = id,
coords = camera.Coords,
rotation = camera.Rotation,
fieldOfView = fov,
sessionSeconds = duration,
useStreamingFocus = camera_config.UseStreamingFocus == true,
},
}
end)