ADD - implement weather app camera views

Add the Weather phone experience with live widgets, configuration, and localized status details.\n\nProvide server-authorized, rate-limited remote weather camera sessions with configurable camera locations and safe client cleanup. Include test-server support and weather store coverage.
This commit is contained in:
Type
2026-08-11 12:52:52 +02:00
parent 872b116977
commit b3db702a2e
18 changed files with 721 additions and 61 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite --port 5174 --strictPort\" \"node testserver/index.cjs 3002\"",
"dev": "concurrently -k -n FRONTEND,BACKEND -c cyan,green \"vite\" \"node testserver/index.cjs\"",
"build": "pnpm typecheck && pnpm build-only",
"build-only": "vite build && node build.cjs",
"preview": "vite preview",
+66 -26
View File
@@ -269,7 +269,7 @@ const phoneFrameImage = computed(
let clockTicker: ReturnType<typeof setInterval> | undefined
let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let unlockedServicesFrame: number | undefined
let unlockedServicesIdle: number | undefined
function getViewportScale(): number {
const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT
@@ -296,27 +296,62 @@ function hydratePhone(payload: PhoneOpenPayload): void {
widgets.hydrate(payload.device?.data.widgets?.payload)
}
function loadUnlockedPhoneData(): void {
if (unlockedServicesLoaded.value) return
unlockedServicesLoaded.value = true
function cancelUnlockedPhoneDataLoad(): void {
if (unlockedServicesIdle === undefined) return
if (typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(unlockedServicesIdle)
} else {
window.clearTimeout(unlockedServicesIdle)
}
unlockedServicesIdle = undefined
}
// Let the lock screen/home screen paint before background app requests compete
// with the first visible NUI frame.
unlockedServicesFrame = window.requestAnimationFrame(() => {
unlockedServicesFrame = undefined
async function bootstrapUnlockedPhoneData(): Promise<void> {
const tasks: Array<() => Promise<unknown> | void> = [
() => calls.bootstrap(),
() => messages.loadConversations(),
() => billing.loadOverview(),
() => mail.bootstrap(account.email),
() => {
if (account.email) return marketplace.loadCounts()
marketplace.setCounts({ active: 0, unread: 0 })
},
() => (account.email ? darkchat.bootstrap() : undefined),
]
for (const task of tasks) {
if (!phone.isOpen || isLocked.value) {
unlockedServicesLoaded.value = false
return
}
try {
await task()
} catch (error) {
console.error('[sky_phone] Failed to bootstrap unlocked phone data.', error)
}
}
}
void mail.bootstrap(account.email)
if (account.email) void marketplace.loadCounts()
else marketplace.setCounts({ active: 0, unread: 0 })
void calls.bootstrap()
void messages.loadConversations()
void billing.loadOverview()
if (account.email) void darkchat.bootstrap()
})
function loadUnlockedPhoneData(): void {
if (unlockedServicesLoaded.value) return
unlockedServicesLoaded.value = true
const startBootstrap = () => {
unlockedServicesIdle = undefined
if (!phone.isOpen || isLocked.value) {
unlockedServicesLoaded.value = false
return
}
void bootstrapUnlockedPhoneData()
}
if (typeof window.requestIdleCallback === 'function') {
unlockedServicesIdle = window.requestIdleCallback(startBootstrap, {
timeout: 1500,
})
} else {
unlockedServicesIdle = window.setTimeout(startBootstrap, 0)
}
}
async function hydrateDevelopmentPhone(): Promise<void> {
@@ -727,6 +762,16 @@ function updateViewportScale(): void {
viewportScale.value = getViewportScale()
}
function completeUnlock(): void {
if (!isUnlocking.value) return
if (unlockTimer !== undefined) {
window.clearTimeout(unlockTimer)
unlockTimer = undefined
}
isUnlocking.value = false
loadUnlockedPhoneData()
}
function finishUnlock(): void {
if (!isLocked.value) return
isUnlocking.value = true
@@ -735,7 +780,8 @@ function finishUnlock(): void {
passcodeError.value = ''
unlockTimer = window.setTimeout(() => {
isUnlocking.value = false
unlockTimer = undefined
completeUnlock()
}, 720)
if (pendingUnlockRoute.value) {
@@ -743,7 +789,6 @@ function finishUnlock(): void {
pendingUnlockRoute.value = null
window.setTimeout(() => void router.push(routePath), 0)
}
loadUnlockedPhoneData()
}
function unlockPhone(): void {
@@ -907,10 +952,7 @@ watch(
(isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
if (unlockedServicesFrame !== undefined) {
window.cancelAnimationFrame(unlockedServicesFrame)
unlockedServicesFrame = undefined
}
cancelUnlockedPhoneDataLoad()
activitySuspended.value = false
weather.stop()
controlCenterOpened.value = false
@@ -956,9 +998,7 @@ watch(
)
onBeforeUnmount(() => {
if (unlockedServicesFrame !== undefined) {
window.cancelAnimationFrame(unlockedServicesFrame)
}
cancelUnlockedPhoneDataLoad()
weather.stop()
if (clockTicker) clearInterval(clockTicker)
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
@@ -1063,7 +1103,7 @@ onBeforeUnmount(() => {
:opened="controlCenterOpened"
@close="controlCenterOpened = false"
/>
<Transition name="lock-screen">
<Transition name="lock-screen" @after-leave="completeUnlock">
<PhoneLockScreen
v-if="isLocked"
:notifications="notifications.lockScreenNotifications"
+175 -7
View File
@@ -2841,13 +2841,68 @@ button {
.weather-app--rain .weather-app__backdrop,
.weather-app--thunder .weather-app__backdrop {
background:
repeating-linear-gradient(
104deg,
transparent 0 22px,
#bcecff17 23px 25px,
transparent 26px 49px
),
linear-gradient(165deg, #354b69 0%, #253952 46%, #101b2d 100%);
radial-gradient(ellipse at 18% 10%, #b9d0e126 0%, transparent 34%),
radial-gradient(ellipse at 82% 28%, #8faec126 0%, transparent 38%),
linear-gradient(165deg, #3f5874 0%, #293e58 44%, #111d30 100%);
}
.weather-app__rain {
position: absolute;
z-index: -1;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.weather-app__rain::after {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
#d8edfa0b 0%,
transparent 30%,
#07112238 100%
);
content: '';
}
.weather-app__rain i {
position: absolute;
top: -24px;
left: var(--rain-left);
width: 1.5px;
height: var(--rain-height);
border-radius: 999px;
background: linear-gradient(
180deg,
transparent,
#c9efff 32%,
#82ccea 78%,
transparent
);
filter: drop-shadow(0 0 2px #9fdfff42);
opacity: 0;
transform: rotate(12deg);
animation: weather-rain-fall var(--rain-duration) linear var(--rain-delay)
infinite;
}
@keyframes weather-rain-fall {
0% {
translate: -18px -5vh;
opacity: 0;
}
12% {
opacity: var(--rain-opacity);
}
78% {
opacity: var(--rain-opacity);
}
100% {
translate: 42px 110vh;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.weather-app__rain i {
display: none;
}
}
.weather-app--thunder .weather-app__backdrop {
background:
@@ -3460,6 +3515,119 @@ 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));
+10 -3
View File
@@ -56,11 +56,18 @@ const phone = usePhoneStore()
const calls = useCallsStore()
const messages = useMessagesStore()
const router = useRouter()
const clock = useClockService()
const usesClock = computed(() =>
['clock', 'date'].includes(props.instance.kind),
)
const usesBank = computed(() =>
['transactions', 'wallet'].includes(props.instance.kind),
)
const usesContacts = computed(() => props.instance.kind === 'contacts')
const clock = useClockService(usesClock)
const weather = useWeatherService()
const music = useMusicService()
const bank = useBankService()
const contactsService = useContactsService()
const bank = useBankService(usesBank)
const contactsService = useContactsService(usesContacts)
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const suppressClick = ref(false)
@@ -11,7 +11,7 @@ import {
kSheet,
kToggle,
} from 'konsta/vue'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
import { WIDGET_REGISTRY_BY_KIND } from '@/config/widgets'
@@ -32,7 +32,9 @@ const emit = defineEmits<{
save: [size: WidgetSize, settings: WidgetSettings]
}>()
const phone = usePhoneStore()
const contactsService = useContactsService()
const contactsService = useContactsService(
computed(() => props.opened && props.instance?.kind === 'contacts'),
)
const size = ref<WidgetSize>('small')
const showDate = ref(true)
const balanceSource = ref<'bank' | 'cash'>('bank')
+56 -16
View File
@@ -1,4 +1,12 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
computed,
onBeforeUnmount,
onMounted,
ref,
toValue,
watch,
type MaybeRefOrGetter,
} from 'vue'
import { useBankingStore } from '@/stores/banking'
import { useCallsStore } from '@/stores/calls'
@@ -9,23 +17,43 @@ import { useWeatherStore } from '@/stores/weather'
const now = ref(new Date())
let clockConsumers = 0
let clockInterval: number | undefined
let contactsRequest: Promise<void> | undefined
export function useClockService() {
export function useClockService(enabled: MaybeRefOrGetter<boolean> = true) {
const phone = usePhoneStore()
onMounted(() => {
let active = false
let mounted = false
function syncClock(): void {
const shouldBeActive = mounted && toValue(enabled)
if (shouldBeActive === active) return
active = shouldBeActive
if (!active) {
clockConsumers -= 1
if (clockConsumers === 0 && clockInterval !== undefined) {
window.clearInterval(clockInterval)
clockInterval = undefined
}
return
}
clockConsumers += 1
if (clockInterval === undefined) {
clockInterval = window.setInterval(() => {
now.value = new Date()
}, 1000)
}
}
watch(() => toValue(enabled), syncClock)
onMounted(() => {
mounted = true
syncClock()
})
onBeforeUnmount(() => {
clockConsumers -= 1
if (clockConsumers === 0 && clockInterval !== undefined) {
window.clearInterval(clockInterval)
clockInterval = undefined
}
mounted = false
syncClock()
})
return {
date: computed(() =>
@@ -92,7 +120,7 @@ export function useMusicService() {
}
}
export function useBankService() {
export function useBankService(enabled: MaybeRefOrGetter<boolean> = true) {
const banking = useBankingStore()
const overview = computed(
() =>
@@ -130,13 +158,18 @@ export function useBankService() {
],
},
)
onMounted(() => {
if (!banking.overview && !banking.isLoading) void banking.load()
})
function loadIfNeeded(): void {
if (toValue(enabled) && !banking.overview && !banking.isLoading) {
void banking.load()
}
}
watch(() => toValue(enabled), loadIfNeeded)
onMounted(loadIfNeeded)
return { overview }
}
export function useContactsService() {
export function useContactsService(enabled: MaybeRefOrGetter<boolean> = true) {
const calls = useCallsStore()
const contacts = computed(() =>
calls.contacts.length
@@ -148,8 +181,15 @@ export function useContactsService() {
{ id: 'mock-liam', name: 'Liam', phone_number: '555-0177' },
],
)
onMounted(() => {
if (!calls.contacts.length) void calls.loadContacts()
})
function loadIfNeeded(): void {
if (!toValue(enabled) || calls.contacts.length || contactsRequest) return
contactsRequest = calls.loadContacts().finally(() => {
contactsRequest = undefined
})
}
watch(() => toValue(enabled), loadIfNeeded)
onMounted(loadIfNeeded)
return { contacts }
}
+22
View File
@@ -2338,6 +2338,28 @@ 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',
+21
View File
@@ -60,4 +60,25 @@ describe('weather store', () => {
expect(weather.forecast).toBe(previous)
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)
})
})
+39 -1
View File
@@ -1,6 +1,10 @@
import { defineStore } from 'pinia'
import type { RawWeatherSnapshot, WeatherForecast } from '@/types/weather'
import type {
RawWeatherSnapshot,
WeatherCamera,
WeatherForecast,
} from '@/types/weather'
import { nuiCall } from '@/utils/nui'
import { buildWeatherForecast } from '@/utils/weather'
@@ -9,12 +13,45 @@ 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
@@ -31,6 +68,7 @@ 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,6 +10,11 @@ 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 -1
View File
@@ -14,7 +14,7 @@ export async function nuiCall<T = unknown>(
'apiPort',
)
const baseUrl = import.meta.env.DEV
? `http://localhost:${developmentPort ?? '3002'}/api`
? `http://localhost:${developmentPort ?? '3001'}/api`
: `https://${resourceName}`
const requestData = import.meta.env.DEV
? {
+83 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
import { kButton, kCard, kLink, kNavbar, kPage, kPreloader } from 'konsta/vue'
import {
CloudSun,
Droplets,
@@ -8,6 +8,7 @@ import {
RefreshCw,
ThermometerSun,
Umbrella,
Video,
Wind,
} from 'lucide-vue-next'
import { computed } from 'vue'
@@ -20,6 +21,38 @@ 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' ||
forecast.value?.condition === 'thunder',
)
const rainDrops = [
['4%', '17px', '1.35s', '-0.3s', '0.32'],
['11%', '11px', '1.7s', '-1.1s', '0.2'],
['18%', '21px', '1.45s', '-0.8s', '0.38'],
['25%', '13px', '1.9s', '-1.6s', '0.24'],
['33%', '18px', '1.55s', '-0.2s', '0.3'],
['40%', '10px', '1.75s', '-1.35s', '0.18'],
['47%', '22px', '1.4s', '-0.65s', '0.36'],
['54%', '14px', '2s', '-1.8s', '0.22'],
['61%', '19px', '1.6s', '-0.45s', '0.34'],
['68%', '12px', '1.8s', '-1.2s', '0.2'],
['75%', '20px', '1.5s', '-0.9s', '0.37'],
['82%', '11px', '1.95s', '-1.55s', '0.2'],
['89%', '17px', '1.45s', '-0.15s', '0.31'],
['96%', '13px', '1.7s', '-1.05s', '0.24'],
].map(([left, height, duration, delay, opacity]) => ({
'--rain-delay': delay,
'--rain-duration': duration,
'--rain-height': height,
'--rain-left': left,
'--rain-opacity': opacity,
}))
const cardColors = {
bgIos: 'bg-transparent',
textIos: 'text-white',
@@ -37,6 +70,12 @@ function formatHour(timestamp: number, index: number): string {
timeZone: 'UTC',
}).format(timestamp)
}
function cameraErrorLabel(): string {
return phone.t(
`Apps.weather.webcam.errors.${weather.cameraError ?? 'request_failed'}`,
)
}
</script>
<template>
@@ -47,6 +86,9 @@ function formatHour(timestamp: number, index: number): string {
:colors="{ bgIos: 'bg-transparent' }"
>
<div class="weather-app__backdrop" aria-hidden="true"></div>
<div v-if="rainy" class="weather-app__rain" aria-hidden="true">
<i v-for="(drop, index) in rainDrops" :key="index" :style="drop"></i>
</div>
<k-navbar class="weather-navbar" :title="phone.t('Apps.weather.name')">
<template #after>
<k-link
@@ -126,6 +168,46 @@ function formatHour(timestamp: number, index: number): 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"
+26 -3
View File
@@ -4765,19 +4765,42 @@ app.post('/api/:endpoint', (request, response) => {
return
}
if (endpoint === 'weather:get') {
const rainyWeather = testScenario === 'weather-rain'
response.json({
success: true,
data: {
clock: { year: 2026, month: 8, day: 5, hour: 17, minute: 20 },
condition: 'partly_cloudy',
condition: rainyWeather ? 'rain' : 'partly_cloudy',
nextCondition: 'rain',
rainLevel: 0.08,
rainLevel: rainyWeather ? 0.82 : 0.08,
region: 'los_santos',
windSpeed: 3.2,
windSpeed: rainyWeather ? 0 : 3.2,
},
})
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,5 +1,6 @@
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.",
@@ -947,6 +948,17 @@ 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
@@ -0,0 +1,32 @@
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,6 +32,7 @@ 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',
@@ -41,6 +42,7 @@ client_scripts {
server_scripts {
'@oxmysql/lib/MySQL.lua',
'config/config.lua',
'config/weather_cameras.lua',
'config/media.lua',
'config/music.lua',
'config/locales/*.lua',
@@ -55,6 +57,7 @@ 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
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,62 @@
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)