mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 03:01:42 +00:00
ENH - refine camera framing and controls
This commit is contained in:
@@ -329,6 +329,7 @@ onBeforeUnmount(() => {
|
||||
class="phone-stage"
|
||||
:class="{
|
||||
'phone-stage--dev': isDevelopment,
|
||||
'phone-stage--landscape': phone.cameraLandscape,
|
||||
'phone-stage--peek': notifications.isPeeking,
|
||||
}"
|
||||
:style="phoneResolutionStyle"
|
||||
|
||||
@@ -251,6 +251,10 @@ button {
|
||||
height: 844px;
|
||||
zoom: var(--phone-zoom, 1);
|
||||
}
|
||||
.phone-stage--landscape .phone-resolution-wrapper--primary {
|
||||
width: 844px;
|
||||
height: 390px;
|
||||
}
|
||||
.phone-device-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -282,6 +286,10 @@ button {
|
||||
box-shadow: none;
|
||||
filter: drop-shadow(0 40px 100px #0009);
|
||||
}
|
||||
.phone-stage--landscape .phone-resolution-wrapper--primary .phone-device {
|
||||
width: 844px;
|
||||
height: 390px;
|
||||
}
|
||||
.phone-device__frame {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
@@ -291,6 +299,16 @@ button {
|
||||
object-fit: fill;
|
||||
pointer-events: none;
|
||||
}
|
||||
.phone-stage--landscape
|
||||
.phone-resolution-wrapper--primary
|
||||
.phone-device__frame {
|
||||
inset: auto;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 390px;
|
||||
height: 844px;
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
.phone-screen {
|
||||
position: relative;
|
||||
container-type: size;
|
||||
@@ -302,6 +320,11 @@ button {
|
||||
background: #08080a;
|
||||
border-radius: 40px;
|
||||
}
|
||||
.phone-stage--landscape .phone-resolution-wrapper--primary .phone-screen {
|
||||
width: 98%;
|
||||
height: 94.5%;
|
||||
margin: auto;
|
||||
}
|
||||
.phone-app {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
|
||||
@@ -12,8 +12,11 @@ type PendingVideo = { blob: Blob; fileName: string }
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
const captureFps = 30
|
||||
const maxCaptureHeight = 720
|
||||
const maxCaptureEdge = 720
|
||||
const portraitAspect = 3 / 4
|
||||
const landscapeAspect = 16 / 9
|
||||
let bitrateBps = 1_500_000
|
||||
let landscape = false
|
||||
let gameView: GameView | null = null
|
||||
let renderFrameId: number | undefined
|
||||
let lastRenderAt = 0
|
||||
@@ -24,6 +27,18 @@ let lastChunkAt = 0
|
||||
let lastChunkTimecode: number | null = null
|
||||
let flushTimer: number | undefined
|
||||
|
||||
function captureDimensions(): { height: number; width: number } {
|
||||
return landscape
|
||||
? {
|
||||
height: Math.round(maxCaptureEdge / landscapeAspect),
|
||||
width: maxCaptureEdge,
|
||||
}
|
||||
: {
|
||||
height: maxCaptureEdge,
|
||||
width: Math.round(maxCaptureEdge * portraitAspect),
|
||||
}
|
||||
}
|
||||
|
||||
function postRecordState(active: boolean, saving = false): void {
|
||||
window.postMessage(
|
||||
{ data: { active, saving }, type: 'camera:recordState' },
|
||||
@@ -35,11 +50,13 @@ function ensureGameView(): GameView {
|
||||
if (!canvasRef.value) throw new Error('capture_failed')
|
||||
if (gameView && !gameView.isLost()) return gameView
|
||||
gameView?.dispose()
|
||||
const scale = Math.min(1, maxCaptureHeight / window.innerHeight)
|
||||
const dimensions = captureDimensions()
|
||||
gameView = createGameView(canvasRef.value)
|
||||
gameView.resize(
|
||||
Math.round(window.innerWidth * scale),
|
||||
Math.round(window.innerHeight * scale),
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
)
|
||||
return gameView
|
||||
}
|
||||
@@ -186,14 +203,13 @@ async function renderFrames(view: GameView, count: number): Promise<void> {
|
||||
}
|
||||
|
||||
async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
const { height, width } = captureDimensions()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const view = createGameView(canvas, { preserveDrawingBuffer: true })
|
||||
try {
|
||||
view.resize(width, height)
|
||||
view.resize(width, height, window.innerWidth, window.innerHeight)
|
||||
await renderFrames(view, 3)
|
||||
const output = document.createElement('canvas')
|
||||
output.width = width
|
||||
@@ -298,6 +314,17 @@ function onMessage(event: MessageEvent): void {
|
||||
void stopRecording(message.data ?? {})
|
||||
} else if (message.type === 'camera:recordCancel') {
|
||||
cleanupRecording()
|
||||
} else if (message.type === 'camera:orientation') {
|
||||
landscape = message.data?.landscape === true
|
||||
if (gameView && !gameView.isLost()) {
|
||||
const dimensions = captureDimensions()
|
||||
gameView.resize(
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
)
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
void uploadReady(message.data as UploadReady)
|
||||
}
|
||||
|
||||
@@ -186,6 +186,8 @@ const defaultLocales: LocaleTree = {
|
||||
name: 'Camera',
|
||||
flash: 'Flash',
|
||||
flip: 'Flip camera',
|
||||
landscape: 'Switch to landscape',
|
||||
portrait: 'Switch to portrait',
|
||||
photo: 'Photo',
|
||||
video: 'Video',
|
||||
focusHelp: 'Space for movement',
|
||||
@@ -585,6 +587,7 @@ function getByPath(source: LocaleTree, path: string): unknown {
|
||||
|
||||
export const usePhoneStore = defineStore('phone', {
|
||||
state: () => ({
|
||||
cameraLandscape: false,
|
||||
currentPage: 1,
|
||||
device: null as PhoneDevice | null,
|
||||
deviceRevisions: {} as Record<string, number>,
|
||||
@@ -604,6 +607,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
},
|
||||
actions: {
|
||||
close(): void {
|
||||
this.cameraLandscape = false
|
||||
this.isOpen = false
|
||||
},
|
||||
open(payload: PhoneOpenPayload = {}): void {
|
||||
@@ -645,6 +649,9 @@ export const usePhoneStore = defineStore('phone', {
|
||||
setCurrentPage(page: number): void {
|
||||
this.currentPage = clampPage(page)
|
||||
},
|
||||
setCameraLandscape(landscape: boolean): void {
|
||||
this.cameraLandscape = landscape
|
||||
},
|
||||
setLaunchOrigin(origin: AppLaunchOrigin | null): void {
|
||||
this.launchOrigin = origin
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -21,13 +21,44 @@ export interface GameView {
|
||||
dispose(): void
|
||||
isLost(): boolean
|
||||
render(): void
|
||||
resize(width: number, height: number): 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,
|
||||
@@ -127,9 +158,6 @@ export function createGameView(
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
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 {
|
||||
@@ -150,10 +178,21 @@ export function createGameView(
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
gl.finish()
|
||||
},
|
||||
resize(width: number, height: number) {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { kFab, kPage, kSegmented, kSegmentedButton, kToast } from 'konsta/vue'
|
||||
import { kFab, kPage, kTabbar, kTabbarLink, kToast } from 'konsta/vue'
|
||||
import {
|
||||
Camera as CameraIcon,
|
||||
Images,
|
||||
RectangleHorizontal,
|
||||
RectangleVertical,
|
||||
RefreshCw,
|
||||
Video,
|
||||
Zap,
|
||||
@@ -13,6 +14,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MediaType, PhoneMedia, UploadResult } from '@/types/media'
|
||||
import { createGameView, type GameView } from '@/utils/gameView'
|
||||
import { formatRecordingDuration, mediaErrorKey } from '@/utils/media'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
@@ -37,25 +39,39 @@ const recordingStartedAt = ref(0)
|
||||
const elapsed = ref('00:00')
|
||||
const captures = ref<CaptureItem[]>([])
|
||||
const latestMedia = ref<PhoneMedia | null>(null)
|
||||
const gameCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const toastOpened = ref(false)
|
||||
const toastText = ref('')
|
||||
const videoBitrateKbps = ref(1500)
|
||||
let shutterTimer: number | undefined
|
||||
let toastTimer: number | undefined
|
||||
let recordingTimer: number | undefined
|
||||
let gameView: GameView | null = null
|
||||
let renderFrameId: number | undefined
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const pendingCount = computed(
|
||||
() =>
|
||||
captures.value.filter((capture) => capture.status === 'uploading').length,
|
||||
)
|
||||
const controlColors = {
|
||||
bgIos: 'bg-black/40',
|
||||
textIos: 'text-white',
|
||||
bgIos: 'bg-ios-light-glass dark:bg-ios-dark-glass',
|
||||
activeBgIos: 'active:bg-white/90 dark:active:bg-white/20',
|
||||
textIos: 'text-black dark:text-white',
|
||||
}
|
||||
const flashColors = computed(() => ({
|
||||
...controlColors,
|
||||
textIos: flashEnabled.value ? 'text-yellow-300' : 'text-white',
|
||||
textIos: flashEnabled.value
|
||||
? 'text-yellow-500 dark:text-yellow-300'
|
||||
: controlColors.textIos,
|
||||
}))
|
||||
const tabbarColors = {
|
||||
bgIos: 'bg-gradient-to-t from-black via-black/90 to-transparent',
|
||||
}
|
||||
const tabColors = {
|
||||
textActiveIos: 'text-yellow-300',
|
||||
textIos: 'text-white/55',
|
||||
}
|
||||
|
||||
function correlationId(): string {
|
||||
return `${Date.now()}-${crypto.randomUUID()}`
|
||||
@@ -175,6 +191,11 @@ function capture(): void {
|
||||
void requestPhoto()
|
||||
}
|
||||
|
||||
function setMode(nextMode: MediaType): void {
|
||||
if (recording.value || savingVideo.value) return
|
||||
mode.value = nextMode
|
||||
}
|
||||
|
||||
async function toggleFlash(): Promise<void> {
|
||||
flashEnabled.value = !flashEnabled.value
|
||||
await nuiCall('camera:setFlash', { enabled: flashEnabled.value })
|
||||
@@ -185,6 +206,47 @@ async function toggleFacing(): Promise<void> {
|
||||
await nuiCall('camera:setFacing', { front: frontCamera.value })
|
||||
}
|
||||
|
||||
function toggleOrientation(): void {
|
||||
if (recording.value || savingVideo.value) return
|
||||
phone.setCameraLandscape(!phone.cameraLandscape)
|
||||
window.postMessage(
|
||||
{
|
||||
data: { landscape: phone.cameraLandscape },
|
||||
type: 'camera:orientation',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
|
||||
function resizeGameView(): void {
|
||||
if (!gameCanvas.value || !gameView) return
|
||||
const bounds = gameCanvas.value.getBoundingClientRect()
|
||||
const renderScale = Math.min(window.devicePixelRatio, 2)
|
||||
gameView.resize(
|
||||
Math.max(1, Math.round(bounds.width * renderScale)),
|
||||
Math.max(1, Math.round(bounds.height * renderScale)),
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
)
|
||||
}
|
||||
|
||||
function startGameView(): void {
|
||||
if (isDevelopment || !gameCanvas.value) return
|
||||
gameView = createGameView(gameCanvas.value)
|
||||
resizeObserver = new ResizeObserver(resizeGameView)
|
||||
resizeObserver.observe(gameCanvas.value)
|
||||
resizeGameView()
|
||||
const render = () => {
|
||||
if (!gameView || gameView.isLost()) {
|
||||
renderFrameId = undefined
|
||||
return
|
||||
}
|
||||
gameView.render()
|
||||
renderFrameId = window.requestAnimationFrame(render)
|
||||
}
|
||||
renderFrameId = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
function updateRecordingTimer(): void {
|
||||
elapsed.value = formatRecordingDuration(Date.now() - recordingStartedAt.value)
|
||||
}
|
||||
@@ -253,6 +315,11 @@ async function loadLatest(): Promise<void> {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
phone.setCameraLandscape(false)
|
||||
window.postMessage(
|
||||
{ data: { landscape: false }, type: 'camera:orientation' },
|
||||
'*',
|
||||
)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('message', onMessage)
|
||||
void nuiCall('camera:setActive', { active: true })
|
||||
@@ -264,6 +331,7 @@ onMounted(() => {
|
||||
},
|
||||
)
|
||||
void loadLatest()
|
||||
startGameView()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -272,6 +340,14 @@ onBeforeUnmount(() => {
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('message', onMessage)
|
||||
if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId)
|
||||
resizeObserver?.disconnect()
|
||||
gameView?.dispose()
|
||||
phone.setCameraLandscape(false)
|
||||
window.postMessage(
|
||||
{ data: { landscape: false }, type: 'camera:orientation' },
|
||||
'*',
|
||||
)
|
||||
window.postMessage({ type: 'camera:recordCancel' }, '*')
|
||||
void nuiCall('camera:setFlash', { enabled: false })
|
||||
void nuiCall('camera:setActive', { active: false })
|
||||
@@ -279,19 +355,25 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-page class="camera-page" :aria-label="phone.t('Apps.camera.name')">
|
||||
<object
|
||||
v-if="!isDevelopment"
|
||||
class="camera-game-view"
|
||||
type="application/x-cfx-game-view"
|
||||
aria-hidden="true"
|
||||
></object>
|
||||
<div v-else class="camera-dev-view" aria-hidden="true">
|
||||
<span class="camera-dev-sun"></span>
|
||||
<span class="camera-dev-horizon"></span>
|
||||
<k-page
|
||||
class="camera-page"
|
||||
:class="{ 'camera-page--landscape': phone.cameraLandscape }"
|
||||
:aria-label="phone.t('Apps.camera.name')"
|
||||
>
|
||||
<div class="camera-viewport">
|
||||
<canvas
|
||||
v-if="!isDevelopment"
|
||||
ref="gameCanvas"
|
||||
class="camera-game-view"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
<div v-else class="camera-dev-view" aria-hidden="true">
|
||||
<span class="camera-dev-sun"></span>
|
||||
<span class="camera-dev-horizon"></span>
|
||||
</div>
|
||||
<div class="camera-shade"></div>
|
||||
<div class="camera-flash" :class="{ active: shutterActive }"></div>
|
||||
</div>
|
||||
<div class="camera-shade"></div>
|
||||
<div class="camera-flash" :class="{ active: shutterActive }"></div>
|
||||
|
||||
<header class="camera-topbar">
|
||||
<k-fab
|
||||
@@ -333,23 +415,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<footer class="camera-controls">
|
||||
<k-segmented class="camera-mode-picker">
|
||||
<k-segmented-button
|
||||
:active="mode === 'photo'"
|
||||
:disabled="recording || savingVideo"
|
||||
@click="mode = 'photo'"
|
||||
>
|
||||
{{ phone.t('Apps.camera.photo') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="mode === 'video'"
|
||||
:disabled="recording || savingVideo"
|
||||
@click="mode = 'video'"
|
||||
>
|
||||
{{ phone.t('Apps.camera.video') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
|
||||
<div class="camera-capture-row">
|
||||
<button
|
||||
class="camera-latest"
|
||||
@@ -385,10 +450,56 @@ onBeforeUnmount(() => {
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
<span class="camera-row-spacer" aria-hidden="true">
|
||||
<CameraIcon :size="22" />
|
||||
</span>
|
||||
<button
|
||||
class="camera-orientation"
|
||||
type="button"
|
||||
:disabled="recording || savingVideo"
|
||||
:aria-label="
|
||||
phone.t(
|
||||
phone.cameraLandscape
|
||||
? 'Apps.camera.portrait'
|
||||
: 'Apps.camera.landscape',
|
||||
)
|
||||
"
|
||||
@click="toggleOrientation"
|
||||
>
|
||||
<RectangleVertical v-if="phone.cameraLandscape" :size="22" />
|
||||
<RectangleHorizontal v-else :size="22" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<k-tabbar labels class="camera-mode-tabbar" :colors="tabbarColors">
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="mode === 'photo'"
|
||||
:class="{
|
||||
'pointer-events-none opacity-50': recording || savingVideo,
|
||||
}"
|
||||
:colors="tabColors"
|
||||
:label="phone.t('Apps.camera.photo')"
|
||||
:link-props="{
|
||||
component: 'button',
|
||||
type: 'button',
|
||||
'aria-disabled': recording || savingVideo,
|
||||
}"
|
||||
@click="setMode('photo')"
|
||||
/>
|
||||
<k-tabbar-link
|
||||
component="button"
|
||||
:active="mode === 'video'"
|
||||
:class="{
|
||||
'pointer-events-none opacity-50': recording || savingVideo,
|
||||
}"
|
||||
:colors="tabColors"
|
||||
:label="phone.t('Apps.camera.video')"
|
||||
:link-props="{
|
||||
component: 'button',
|
||||
type: 'button',
|
||||
'aria-disabled': recording || savingVideo,
|
||||
}"
|
||||
@click="setMode('video')"
|
||||
/>
|
||||
</k-tabbar>
|
||||
</footer>
|
||||
|
||||
<k-toast
|
||||
@@ -408,6 +519,23 @@ onBeforeUnmount(() => {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
.camera-viewport {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
overflow: hidden;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.camera-page--landscape .camera-viewport {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.camera-game-view,
|
||||
.camera-dev-view,
|
||||
.camera-shade,
|
||||
@@ -481,10 +609,11 @@ onBeforeUnmount(() => {
|
||||
gap: 10px;
|
||||
}
|
||||
.camera-control {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
--color-primary: #8e8e93;
|
||||
}
|
||||
.camera-control svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
.camera-focus-pill,
|
||||
.camera-upload-pill {
|
||||
@@ -528,29 +657,24 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
right: 0;
|
||||
bottom: 35px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.camera-mode-picker {
|
||||
align-self: center;
|
||||
width: 188px;
|
||||
padding: 2px;
|
||||
border-radius: 10px;
|
||||
background: #0007;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.camera-capture-row {
|
||||
display: grid;
|
||||
grid-template-columns: 54px 1fr 54px;
|
||||
align-items: center;
|
||||
padding: 0 24px 4px;
|
||||
}
|
||||
.camera-mode-tabbar {
|
||||
width: 100%;
|
||||
}
|
||||
.camera-latest,
|
||||
.camera-row-spacer {
|
||||
.camera-orientation {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
@@ -561,15 +685,17 @@ onBeforeUnmount(() => {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.camera-orientation {
|
||||
justify-self: end;
|
||||
}
|
||||
.camera-orientation:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.camera-latest img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.camera-row-spacer {
|
||||
justify-self: end;
|
||||
visibility: hidden;
|
||||
}
|
||||
.camera-shutter {
|
||||
justify-self: center;
|
||||
width: 76px;
|
||||
|
||||
@@ -62,7 +62,8 @@ Locales["en"] = {
|
||||
},
|
||||
calculator = { name = "Calculator" },
|
||||
camera = {
|
||||
name = "Camera", flash = "Flash", flip = "Flip camera", photo = "Photo", video = "Video",
|
||||
name = "Camera", flash = "Flash", flip = "Flip camera", landscape = "Switch to landscape",
|
||||
portrait = "Switch to portrait", photo = "Photo", video = "Video",
|
||||
focusHelp = "Space for movement", returnHelp = "Space to return", uploading = "{count} uploading",
|
||||
saving = "Saving video...", openGallery = "Open Gallery", takePhoto = "Take photo",
|
||||
startRecording = "Start recording", stopRecording = "Stop recording", saved = "Saved to Gallery.",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sky Phone</title>
|
||||
<script type="module" crossorigin src="./assets/sky-index-BZSEyvMU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-StMblc29.css">
|
||||
<script type="module" crossorigin src="./assets/sky-index-BemolEtl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-D03h3vHf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user