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.
This commit is contained in:
Leon.Schmidt
2026-08-15 22:19:28 +02:00
parent 493375bbfa
commit 387c27cc7f
8 changed files with 249 additions and 100 deletions
@@ -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()
+5 -5
View File
@@ -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([
+1 -1
View File
@@ -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)
@@ -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')
})
})
+155 -92
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { kFab, kNavbar, kPage, kSegmented, kSegmentedButton } from 'konsta/vue'
import { kFab, kPage } from 'konsta/vue'
import {
ArrowLeft,
Images,
LockKeyhole,
LockOpen,
Mic,
MicOff,
RefreshCw,
@@ -17,6 +19,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
import { SkySegmented, SkySegmentedButton } from '@/ui'
import { createGameView, type GameView } from '@/utils/gameView'
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
import { nuiCall } from '@/utils/nui'
@@ -31,6 +34,8 @@ type CaptureItem = {
const isDevelopment = import.meta.env.DEV
const zoomLevels = [0.5, 1, 2, 3] as const
const minimumZoom = zoomLevels[0]
const maximumZoom = zoomLevels[zoomLevels.length - 1]
const phone = usePhoneStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
@@ -40,12 +45,13 @@ const requestedMessageMedia = computed<MediaType | null>(() => {
return value === 'photo' || value === 'video' ? value : null
})
const mode = ref<MediaType>(requestedMessageMedia.value ?? 'photo')
const selectedZoom = ref<(typeof zoomLevels)[number]>(1)
const selectedZoom = ref(1)
const flashEnabled = ref(false)
const microphoneEnabled = ref(true)
const frontCamera = ref(false)
const shutterActive = ref(false)
const focused = ref(true)
const cameraLocked = ref(false)
const recording = ref(false)
const savingVideo = ref(false)
const recordingStartedAt = ref(0)
@@ -62,8 +68,6 @@ let recordingTimer: number | undefined
let gameView: GameView | null = null
let renderFrameId: number | undefined
let resizeObserver: ResizeObserver | null = null
let wheelDelta = 0
let wheelResetTimer: number | undefined
const pendingCount = computed(
() =>
@@ -74,15 +78,6 @@ const controlColors = {
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
textIos: 'text-black/80 dark:text-white/80',
}
const modeColors = {
strongHighlightBgIos: 'bg-[#e5e5ea] dark:bg-[#2c2c2e]',
}
const modeButtonColors = {
segmentedStrongTextIos: 'text-[#ffd60a]',
}
const modeNavbarColors = {
bgIos: 'bg-transparent',
}
const flashColors = computed(() => ({
...controlColors,
textIos: flashEnabled.value
@@ -234,6 +229,11 @@ async function toggleFacing(): Promise<void> {
await nuiCall('camera:setFacing', { front: frontCamera.value })
}
async function toggleCameraLock(): Promise<void> {
cameraLocked.value = !cameraLocked.value
await nuiCall('camera:setLocked', { locked: cameraLocked.value })
}
function toggleOrientation(): void {
if (recording.value || savingVideo.value) return
phone.setCameraLandscape(!phone.cameraLandscape)
@@ -249,31 +249,23 @@ function toggleOrientation(): void {
})
}
function setZoom(zoom: (typeof zoomLevels)[number]): void {
selectedZoom.value = zoom
function setZoom(zoom: number): void {
const normalizedZoom = Math.min(
maximumZoom,
Math.max(minimumZoom, Math.round(zoom * 100) / 100),
)
if (normalizedZoom === selectedZoom.value) return
selectedZoom.value = normalizedZoom
resizeGameView()
window.postMessage({ data: { zoom }, type: 'camera:zoom' }, '*')
void nuiCall('camera:setZoom', { zoom })
window.postMessage(
{ data: { zoom: normalizedZoom }, type: 'camera:zoom' },
'*',
)
void nuiCall('camera:setZoom', { zoom: normalizedZoom })
}
function zoomWithWheel(event: WheelEvent): void {
wheelDelta += event.deltaY
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
wheelResetTimer = window.setTimeout(() => {
wheelDelta = 0
wheelResetTimer = undefined
}, 140)
if (Math.abs(wheelDelta) < 35) return
const currentIndex = zoomLevels.indexOf(selectedZoom.value)
const nextIndex = Math.min(
zoomLevels.length - 1,
Math.max(0, currentIndex + (wheelDelta < 0 ? 1 : -1)),
)
wheelDelta = 0
const nextZoom = zoomLevels[nextIndex]
if (nextZoom !== undefined && nextZoom !== selectedZoom.value)
setZoom(nextZoom)
setZoom(selectedZoom.value - event.deltaY * 0.0025)
}
function resizeGameView(entry?: ResizeObserverEntry): void {
@@ -328,7 +320,13 @@ function updateRecordingTimer(): void {
}
function onKeydown(event: KeyboardEvent): void {
if (event.code !== 'Space' || event.repeat || !focused.value) return
if (
event.code !== 'Space' ||
event.repeat ||
!focused.value ||
cameraLocked.value
)
return
event.preventDefault()
focused.value = false
void nuiCall('camera:setFocus', { focused: false })
@@ -425,7 +423,6 @@ onBeforeUnmount(() => {
if (shutterTimer !== undefined) window.clearTimeout(shutterTimer)
if (noticeTimer !== undefined) window.clearTimeout(noticeTimer)
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
if (wheelResetTimer !== undefined) window.clearTimeout(wheelResetTimer)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('message', onMessage)
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
@@ -459,7 +456,7 @@ onBeforeUnmount(() => {
<div
v-else
class="camera-dev-view"
:style="{ transform: `scale(${Math.max(1, selectedZoom)})` }"
:style="{ transform: `scale(${selectedZoom})` }"
aria-hidden="true"
>
<span class="camera-dev-sun"></span>
@@ -526,7 +523,25 @@ onBeforeUnmount(() => {
<span v-else-if="pendingCount" class="camera-upload-pill">
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
</span>
<span v-else class="camera-topbar-spacer" aria-hidden="true"></span>
<button
v-else
class="camera-focus-pill camera-lock-control"
:class="{ 'camera-lock-control--active': cameraLocked }"
type="button"
:aria-label="
phone.t(
cameraLocked
? 'Apps.camera.unlockCamera'
: 'Apps.camera.lockCamera',
)
"
:aria-pressed="cameraLocked"
@click="toggleCameraLock"
>
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
</button>
<k-fab
component="button"
type="button"
@@ -551,19 +566,21 @@ onBeforeUnmount(() => {
{{ savingVideo ? phone.t('Apps.camera.saving') : elapsed }}
</div>
<div class="camera-zoom-row">
<button
v-for="zoom in zoomLevels"
:key="zoom"
class="camera-zoom-pill"
:class="{ active: selectedZoom === zoom }"
type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="selectedZoom === zoom"
@click="setZoom(zoom)"
>
{{ zoom }}x
</button>
<div class="camera-zoom-control">
<div class="camera-zoom-row">
<button
v-for="zoom in zoomLevels"
:key="zoom"
class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03"
@click="setZoom(zoom)"
>
{{ zoom }}x
</button>
</div>
</div>
<footer class="camera-controls">
@@ -621,40 +638,30 @@ onBeforeUnmount(() => {
</k-fab>
</div>
<k-navbar
component="nav"
<SkySegmented
class="camera-mode-navbar"
inner-class="hidden"
:colors="modeNavbarColors"
:active-index="mode === 'photo' ? 0 : 1"
:aria-label="phone.t('Apps.camera.name')"
:item-count="2"
navigation
compact
strong
>
<template #subnavbar>
<k-segmented strong rounded :colors="modeColors">
<k-segmented-button
small
:active="mode === 'photo'"
:disabled="recording || savingVideo"
:colors="modeButtonColors"
:class="mode === 'photo' ? undefined : 'text-[#8e8e93]'"
:aria-pressed="mode === 'photo'"
@click="setMode('photo')"
>
{{ phone.t('Apps.camera.photo') }}
</k-segmented-button>
<k-segmented-button
small
:active="mode === 'video'"
:disabled="recording || savingVideo"
:colors="modeButtonColors"
:class="mode === 'video' ? undefined : 'text-[#8e8e93]'"
:aria-pressed="mode === 'video'"
@click="setMode('video')"
>
{{ phone.t('Apps.camera.video') }}
</k-segmented-button>
</k-segmented>
</template>
</k-navbar>
<SkySegmentedButton
:active="mode === 'photo'"
:disabled="recording || savingVideo"
@click="setMode('photo')"
>
{{ phone.t('Apps.camera.photo') }}
</SkySegmentedButton>
<SkySegmentedButton
:active="mode === 'video'"
:disabled="recording || savingVideo"
@click="setMode('video')"
>
{{ phone.t('Apps.camera.video') }}
</SkySegmentedButton>
</SkySegmented>
</footer>
</k-page>
</template>
@@ -697,8 +704,13 @@ onBeforeUnmount(() => {
object-fit: cover;
}
.camera-dev-view {
inset: -50%;
width: 200%;
height: 200%;
overflow: hidden;
background: linear-gradient(#4a88ad 0 48%, #a88d68 49% 62%, #283528 63%);
transform-origin: center;
transition: transform 160ms ease;
}
.camera-dev-sun {
position: absolute;
@@ -812,6 +824,31 @@ onBeforeUnmount(() => {
.camera-focus-pill--notice {
color: #ffd60a;
}
.camera-lock-control {
min-height: 44px;
border: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
color: #fff;
cursor: pointer;
}
.camera-lock-control--active {
color: #ffd60a;
}
.camera-lock-control kbd {
padding: 1px 5px;
border: 1px solid rgb(255 255 255 / 24%);
border-radius: 5px;
background: rgb(255 255 255 / 10%);
color: inherit;
font: inherit;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.camera-record-status {
position: absolute;
z-index: 4;
@@ -833,15 +870,25 @@ onBeforeUnmount(() => {
border-radius: 50%;
background: #ff3b30;
}
.camera-zoom-row {
.camera-zoom-control {
position: absolute;
z-index: 4;
bottom: 164px;
bottom: 196px;
left: 50%;
display: flex;
gap: 6px;
width: 140px;
padding: 4px 6px;
border-radius: 999px;
background: rgb(18 18 20 / 72%);
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
transform: translateX(-50%);
}
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 6px;
}
.camera-zoom-pill {
width: 30px;
height: 26px;
@@ -879,7 +926,7 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: 54px 1fr 54px;
align-items: center;
padding: 0 24px 4px;
padding: 0 24px 32px;
}
.camera-latest {
width: 44px;
@@ -930,11 +977,27 @@ onBeforeUnmount(() => {
opacity: 0.5;
}
.camera-mode-navbar {
position: relative;
top: auto;
width: 50%;
width: 54%;
align-self: center;
padding-bottom: 18px;
padding-top: 0;
margin-bottom: 18px;
transform: translateY(-16px);
}
.camera-mode-navbar.sky-segmented--navigation {
border-color: rgb(255 255 255 / 14%);
background: rgb(28 28 30 / 88%);
}
.camera-mode-navbar :deep(.sky-segmented__highlight) {
background: #3a3a3c;
}
.camera-mode-navbar :deep(.sky-segmented-button) {
color: #8e8e93;
font-size: 12px;
}
.camera-mode-navbar :deep(.sky-segmented-button--active) {
color: #ffd60a;
}
.camera-mode-navbar :deep(.sky-segmented-button:focus-visible) {
outline: 0;
box-shadow: none;
}
</style>
+1
View File
@@ -11,6 +11,7 @@ app.use(express.json({ limit: '3mb' }))
const lifecycleEndpoints = new Set([
'camera:setActive',
'camera:setLocked',
'camera:setFacing',
'camera:setFlash',
'camera:setFocus',
+1
View File
@@ -518,6 +518,7 @@ async function main() {
const lifecycleEndpoints = [
'camera:setActive',
'camera:setLocked',
'camera:setFacing',
'camera:setFlash',
'camera:setFocus',
+2 -1
View File
@@ -1352,7 +1352,8 @@ Locales["en"] = {
camera = {
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
portrait = "Switch to portrait", photo = "Photo", video = "Video", microphoneOn = "Microphone on", microphoneOff = "Microphone muted",
focusHelp = "Space for movement", returnHelp = "Space to return", uploading = "{count} uploading",
focusHelp = "Hold Space to look around", returnHelp = "Release Space for controls", lockCamera = "Lock camera movement",
unlockCamera = "Unlock camera movement", spaceKey = "Space", uploading = "{count} uploading",
saving = "Saving video...", openGallery = "Open Photos", takePhoto = "Take photo",
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Photos.",
zoom = "Set camera zoom to {zoom}",