mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-03 03:38:52 +00:00
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:
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user