ENH - integrate camera and gallery updates

# Conflicts:
#	frontend/src/App.vue
#	frontend/src/config/apps.test.ts
#	sky_phone/source/html/index.html
This commit is contained in:
smx.pusha
2026-08-06 14:59:21 +02:00
39 changed files with 3185 additions and 954 deletions
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { coverTextureCoordinates } from '@/utils/gameView'
describe('coverTextureCoordinates', () => {
it('center-crops a widescreen game view for 3:4 portrait output', () => {
expect(Array.from(coverTextureCoordinates(1920, 1080, 540, 720))).toEqual([
expect.closeTo(0.29, 2),
0,
expect.closeTo(0.71, 2),
0,
expect.closeTo(0.29, 2),
1,
expect.closeTo(0.71, 2),
1,
])
})
it('keeps the full game view for 16:9 landscape output', () => {
expect(Array.from(coverTextureCoordinates(1920, 1080, 720, 405))).toEqual([
0, 0, 1, 0, 0, 1, 1, 1,
])
})
it('keeps the full texture when both aspect ratios match', () => {
expect(Array.from(coverTextureCoordinates(1600, 900, 800, 450))).toEqual([
0, 0, 1, 0, 0, 1, 1, 1,
])
})
})
+204
View File
@@ -0,0 +1,204 @@
const VERTEX_SHADER = `
attribute vec2 a_position;
attribute vec2 a_texcoord;
varying vec2 v_texcoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texcoord = a_texcoord;
}
`
const FRAGMENT_SHADER = `
varying highp vec2 v_texcoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texcoord);
}
`
export interface GameView {
readonly canvas: HTMLCanvasElement
dispose(): void
isLost(): boolean
render(): void
resize(
width: number,
height: number,
sourceWidth?: number,
sourceHeight?: number,
): void
}
export interface GameViewOptions {
preserveDrawingBuffer?: boolean
}
export function coverTextureCoordinates(
sourceWidth: number,
sourceHeight: number,
targetWidth: number,
targetHeight: number,
): Float32Array {
const sourceAspect = sourceWidth / sourceHeight
const targetAspect = targetWidth / targetHeight
let left = 0
let right = 1
let top = 0
let bottom = 1
if (sourceAspect > targetAspect) {
const visibleWidth = targetAspect / sourceAspect
left = (1 - visibleWidth) / 2
right = 1 - left
} else if (sourceAspect < targetAspect) {
const visibleHeight = sourceAspect / targetAspect
top = (1 - visibleHeight) / 2
bottom = 1 - top
}
return new Float32Array([left, top, right, top, left, bottom, right, bottom])
}
function compileShader(
gl: WebGLRenderingContext,
type: number,
source: string,
): WebGLShader {
const shader = gl.createShader(type)
if (!shader) throw new Error('game_view_shader_unavailable')
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) || 'game_view_shader_failed')
}
return shader
}
export function createGameView(
canvas: HTMLCanvasElement,
options: GameViewOptions = {},
): GameView {
const gl = canvas.getContext('webgl', {
alpha: false,
antialias: false,
depth: false,
desynchronized: true,
failIfMajorPerformanceCaveat: false,
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
stencil: false,
}) as WebGLRenderingContext | null
if (!gl) throw new Error('game_view_unavailable')
let lost = false
let disposed = false
const onContextLost = (event: Event) => {
event.preventDefault()
lost = true
console.error('[Camera] Game-view WebGL context lost.')
}
canvas.addEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
const program = gl.createProgram()
if (!program) throw new Error('game_view_program_unavailable')
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER))
gl.attachShader(
program,
compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER),
)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
}
gl.useProgram(program)
const positionLocation = gl.getAttribLocation(program, 'a_position')
const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
if (positionLocation < 0 || texcoordLocation < 0) {
throw new Error('game_view_attributes_unavailable')
}
const positionBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
const texcoordBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
gl.STATIC_DRAW,
)
gl.enableVertexAttribArray(texcoordLocation)
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
// game backbuffer. These calls are intentionally not redundant.
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
return {
canvas,
dispose() {
if (disposed) return
disposed = true
canvas.removeEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
isLost: () => lost,
render() {
if (disposed || lost) return
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.finish()
},
resize(
width: number,
height: number,
sourceWidth = window.innerWidth,
sourceHeight = window.innerHeight,
) {
if (disposed || lost) return
canvas.width = width
canvas.height = height
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
coverTextureCoordinates(sourceWidth, sourceHeight, width, height),
gl.DYNAMIC_DRAW,
)
gl.viewport(0, 0, width, height)
},
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
filterMedia,
formatRecordingDuration,
mediaErrorKey,
mergeMedia,
} from './media'
const media = [
{ createdAt: 10, id: 1, mediaType: 'photo' as const, url: 'photo' },
{ createdAt: 20, id: 2, mediaType: 'video' as const, url: 'video' },
]
describe('media utilities', () => {
it('filters gallery media by explicit type', () => {
expect(filterMedia(media, 'all')).toHaveLength(2)
expect(filterMedia(media, 'photo').map((entry) => entry.id)).toEqual([1])
expect(filterMedia(media, 'video').map((entry) => entry.id)).toEqual([2])
})
it('merges pages without duplicates and keeps newest first', () => {
expect(
mergeMedia(media, [
{ createdAt: 30, id: 1, mediaType: 'photo', url: 'updated' },
{ createdAt: 25, id: 3, mediaType: 'photo', url: 'new' },
]).map((entry) => [entry.id, entry.url]),
).toEqual([
[1, 'updated'],
[3, 'new'],
[2, 'video'],
])
})
it('formats unlimited recording durations', () => {
expect(formatRecordingDuration(0)).toBe('00:00')
expect(formatRecordingDuration(3_725_000)).toBe('62:05')
})
it('maps unknown server failures to the localized default', () => {
expect(mediaErrorKey('upload_timeout')).toBe('upload_timeout')
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
+53
View File
@@ -0,0 +1,53 @@
import type { GalleryFilter, MediaType, PhoneMedia } from '@/types/media'
export function isMediaType(value: unknown): value is MediaType {
return value === 'photo' || value === 'video'
}
export function filterMedia(
media: PhoneMedia[],
filter: GalleryFilter,
): PhoneMedia[] {
return filter === 'all'
? media
: media.filter((entry) => entry.mediaType === filter)
}
export function mergeMedia(
current: PhoneMedia[],
incoming: PhoneMedia[],
): PhoneMedia[] {
const byId = new Map(current.map((entry) => [entry.id, entry]))
for (const entry of incoming) byId.set(entry.id, entry)
return [...byId.values()].sort(
(left, right) => right.createdAt - left.createdAt || right.id - left.id,
)
}
export function formatRecordingDuration(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
export function mediaErrorKey(error?: string): string {
const known = new Set([
'cancelled',
'capture_failed',
'invalid_media_type',
'invalid_upload',
'invalid_upload_token',
'missing_config',
'not_found',
'operation_in_progress',
'owner_changed',
'rate_limited',
'request_failed',
'request_timeout',
'unsupported',
'upload_failed',
'upload_timeout',
])
return known.has(error ?? '') ? (error as string) : 'request_failed'
}
+2 -3
View File
@@ -16,7 +16,7 @@ describe('preferences', () => {
notificationVolume: 45,
notificationDurationSeconds: 14,
notifications: {
camera: { enabled: false, sounds: false },
clock: { enabled: false, sounds: false },
},
phoneScale: 110,
wallpaper: 'ember',
@@ -26,11 +26,10 @@ describe('preferences', () => {
expect(value.settings.appearanceMode).toBe('light')
expect(value.settings.notificationVolume).toBe(45)
expect(value.settings.notificationDurationSeconds).toBe(14)
expect(value.settings.notifications.camera).toEqual({
expect(value.settings.notifications.clock).toEqual({
enabled: false,
sounds: false,
})
expect(value.settings.notifications.clock.enabled).toBe(true)
expect(value.settings.notifications.mail).toEqual({
enabled: true,
sounds: true,
+6 -6
View File
@@ -1,4 +1,4 @@
import type { PhoneAppId } from '@/types/apps'
import type { LaunchablePhoneAppId } from '@/types/apps'
export const APPEARANCE_MODE_IDS = ['automatic', 'light', 'dark'] as const
export const PHONE_FRAME_IDS = [
@@ -33,7 +33,7 @@ export type PhonePreferencesV1 = {
notificationSound: NotificationSoundId
notificationDurationSeconds: number
notificationVolume: number
notifications: Record<PhoneAppId, AppNotificationPreferences>
notifications: Record<LaunchablePhoneAppId, AppNotificationPreferences>
phoneScale: number
ringtone: RingtoneId
ringtoneVolume: number
@@ -44,7 +44,7 @@ export type PhonePreferencesV1 = {
}
const DEFAULT_APP_NOTIFICATIONS: Record<
PhoneAppId,
LaunchablePhoneAppId,
AppNotificationPreferences
> = {
phone: { enabled: true, sounds: true },
@@ -112,16 +112,16 @@ function readChoice<T extends string>(
function readNotifications(
value: unknown,
): Record<PhoneAppId, AppNotificationPreferences> {
): Record<LaunchablePhoneAppId, AppNotificationPreferences> {
const source =
value && typeof value === 'object'
? (value as Partial<
Record<PhoneAppId, Partial<AppNotificationPreferences>>
Record<LaunchablePhoneAppId, Partial<AppNotificationPreferences>>
>)
: {}
const notifications = structuredClone(DEFAULT_APP_NOTIFICATIONS)
for (const appId of Object.keys(notifications) as PhoneAppId[]) {
for (const appId of Object.keys(notifications) as LaunchablePhoneAppId[]) {
notifications[appId] = {
enabled: readBoolean(
source[appId]?.enabled,