From 387c27cc7fcc9d1c735838db15afe60b144061c2 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sat, 15 Aug 2026 22:19:28 +0200 Subject: [PATCH] ENH - refine camera capture controls Move Photo and Video selection onto Sky UI, separate the bottom controls, and expose the camera movement lock with localized labels. Support smooth wheel zoom down to 0.5x inside the phone crop and extend browser lifecycle coverage and camera contracts. --- frontend/src/components/PhoneMediaCapture.vue | 2 +- frontend/src/utils/gameView.test.ts | 10 +- frontend/src/utils/gameView.ts | 2 +- .../src/views/apps/CameraApp.contract.test.ts | 83 ++++++ frontend/src/views/apps/CameraApp.vue | 247 +++++++++++------- frontend/testserver/index.cjs | 1 + frontend/testserver/smoke.cjs | 1 + sky_phone/config/locales/en.lua | 3 +- 8 files changed, 249 insertions(+), 100 deletions(-) create mode 100644 frontend/src/views/apps/CameraApp.contract.test.ts diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index b10eaa9..6b63411 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -510,7 +510,7 @@ function onMessage(event: MessageEvent): void { } } else if (message.type === 'camera:zoom') { const nextZoom = Number(message.data?.zoom) - if (![0.5, 1, 2, 3].includes(nextZoom)) return + if (!Number.isFinite(nextZoom) || nextZoom < 0.5 || nextZoom > 3) return zoom = nextZoom if (gameView && !gameView.isLost()) { const dimensions = captureDimensions() diff --git a/frontend/src/utils/gameView.test.ts b/frontend/src/utils/gameView.test.ts index 39112dd..f4482dd 100644 --- a/frontend/src/utils/gameView.test.ts +++ b/frontend/src/utils/gameView.test.ts @@ -31,16 +31,16 @@ describe('gameViewGeometry', () => { ]) }) - it('keeps 0.5x full-frame while higher zoom levels crop around the center', () => { + it('widens the phone crop at 0.5x while higher zoom levels crop around the center', () => { const wideGeometry = gameViewGeometry(1920, 1080, 540, 720, 0.5) expect(Array.from(wideGeometry.textureCoordinates)).toEqual([ - expect.closeTo(0.29, 2), + expect.closeTo(0.08, 2), 0, - expect.closeTo(0.71, 2), + expect.closeTo(0.92, 2), 0, - expect.closeTo(0.29, 2), + expect.closeTo(0.08, 2), 1, - expect.closeTo(0.71, 2), + expect.closeTo(0.92, 2), 1, ]) expect(Array.from(wideGeometry.positions)).toEqual([ diff --git a/frontend/src/utils/gameView.ts b/frontend/src/utils/gameView.ts index 1485c98..db771be 100644 --- a/frontend/src/utils/gameView.ts +++ b/frontend/src/utils/gameView.ts @@ -60,7 +60,7 @@ export function gameViewGeometry( bottom = 1 - top } - const normalizedZoom = Math.min(3, Math.max(1, zoom)) + const normalizedZoom = Math.min(3, Math.max(0.5, zoom)) const centerX = (left + right) / 2 const centerY = (top + bottom) / 2 left = Math.max(0, centerX + (left - centerX) / normalizedZoom) diff --git a/frontend/src/views/apps/CameraApp.contract.test.ts b/frontend/src/views/apps/CameraApp.contract.test.ts new file mode 100644 index 0000000..47a9906 --- /dev/null +++ b/frontend/src/views/apps/CameraApp.contract.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const cameraView = readFileSync( + new URL('./CameraApp.vue', import.meta.url), + 'utf8', +) +const mediaCapture = readFileSync( + new URL('../../components/PhoneMediaCapture.vue', import.meta.url), + 'utf8', +) +const cameraClient = readFileSync( + new URL('../../../../sky_phone/source/client/camera.lua', import.meta.url), + 'utf8', +) +const cameraConfig = readFileSync( + new URL('../../../../sky_phone/config/config.lua', import.meta.url), + 'utf8', +) +const cameraAnimations = readFileSync( + new URL( + '../../../../sky_phone/source/client/animations.lua', + import.meta.url, + ), + 'utf8', +) + +describe('Camera app controls', () => { + it('uses the Sky UI moving segment for photo and video modes', () => { + expect(cameraView).toContain('SkySegmented') + expect(cameraView).toContain(':active-index="mode === \'photo\' ? 0 : 1"') + expect(cameraView).not.toContain('SkyPillNavigation') + expect(cameraView).not.toContain('k-segmented') + expect(cameraView).not.toContain('k-navbar') + }) + + it('keeps continuous wheel zoom without an extra slider bar', () => { + expect(cameraView).not.toContain('camera-zoom-slider') + expect(cameraView).not.toContain('type="range"') + expect(cameraView).toContain('event.deltaY * 0.0025') + expect(mediaCapture).toContain('nextZoom < 0.5 || nextZoom > 3') + expect(mediaCapture).not.toContain('[0.5, 1, 2, 3].includes(nextZoom)') + }) + + it('renders the development preview differently at 0.5x and 1x', () => { + expect(cameraView).toContain('transform: `scale(${selectedZoom})`') + expect(cameraView).not.toContain('Math.max(1, selectedZoom)') + expect(cameraView).toMatch( + /\.camera-dev-view\s*\{[^}]*inset:\s*-50%;[^}]*width:\s*200%;[^}]*height:\s*200%;/s, + ) + }) + + it('locks look controls without changing the global gameplay camera', () => { + expect(cameraView).toContain("nuiCall('camera:setLocked'") + expect(cameraView).toContain('cameraLocked.value') + expect(cameraView).toContain('Apps.camera.spaceKey') + expect(cameraClient).toContain('RegisterNUICallback("camera:setLocked"') + expect(cameraClient).toContain('INPUT_LOOK_LR') + expect(cameraClient).toContain('INPUT_LOOK_UD') + expect(cameraClient).toContain('first_person_view_mode = 4') + expect(cameraClient).toContain( + 'SetFollowPedCamViewMode(first_person_view_mode)', + ) + expect(cameraClient).toContain( + 'SetFollowVehicleCamViewMode(first_person_view_mode)', + ) + expect(cameraClient).not.toContain('ultrawide_camera_handle') + expect(cameraClient).not.toContain('ensure_ultrawide_camera') + }) + + it('uses a looping camera-hold pose instead of the old selfie dance', () => { + expect(cameraConfig).toContain('Camera = "cellphone@self"') + expect(cameraConfig).toContain('Camera = "selfie"') + expect(cameraAnimations).toContain( + 'mode == MODE_CAMERA_REAR or mode == MODE_CAMERA_SELFIE', + ) + expect(cameraAnimations).toContain( + 'Config.Animations.Dictionaries.Camera, Config.Animations.Clips.Camera', + ) + expect(cameraConfig).not.toContain('anim@mp_player_intuppertake_selfie') + }) +}) diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index fc5148a..dd183d6 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -1,8 +1,10 @@