mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
FIX - stabilize CEF media capture and uploads
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Camera, Pause, Play } from 'lucide-vue-next'
|
||||
import { Camera, Pause, Play, TriangleAlert } from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { SmsMessageType } from '@/types/messages'
|
||||
|
||||
type MessageAttachment = {
|
||||
@@ -11,7 +12,9 @@ type MessageAttachment = {
|
||||
}
|
||||
|
||||
const props = defineProps<{ message: MessageAttachment }>()
|
||||
const phone = usePhoneStore()
|
||||
const playing = ref(false)
|
||||
const playbackFailed = ref(false)
|
||||
const video = ref<HTMLVideoElement>()
|
||||
|
||||
const imageStyles: Record<string, string> = {
|
||||
@@ -55,9 +58,18 @@ async function toggleVideo(): Promise<void> {
|
||||
playing.value = !playing.value
|
||||
return
|
||||
}
|
||||
if (video.value.paused) await video.value.play()
|
||||
else video.value.pause()
|
||||
playing.value = !video.value.paused
|
||||
if (!video.value.paused) {
|
||||
video.value.pause()
|
||||
return
|
||||
}
|
||||
playbackFailed.value = false
|
||||
try {
|
||||
await video.value.play()
|
||||
} catch (error) {
|
||||
playing.value = false
|
||||
playbackFailed.value = true
|
||||
console.error('[Messages] Could not play the attached video.', error)
|
||||
}
|
||||
}
|
||||
|
||||
function durationLabel(milliseconds: number | null): string {
|
||||
@@ -85,8 +97,13 @@ function durationLabel(milliseconds: number | null): string {
|
||||
v-else-if="message.message_type === 'video'"
|
||||
type="button"
|
||||
class="messages-attachment messages-attachment--video"
|
||||
:class="{ playing }"
|
||||
:class="{ playing, 'messages-attachment--failed': playbackFailed }"
|
||||
:style="{ background }"
|
||||
:aria-label="
|
||||
playbackFailed
|
||||
? phone.t('Apps.photos.errors.unsupported')
|
||||
: phone.t('Apps.photos.videoAlt')
|
||||
"
|
||||
@click="toggleVideo"
|
||||
>
|
||||
<video
|
||||
@@ -95,15 +112,25 @@ function durationLabel(milliseconds: number | null): string {
|
||||
:src="mediaUrl"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@ended="playing = false"
|
||||
@error="playbackFailed = true"
|
||||
/>
|
||||
<span
|
||||
><Pause v-if="playing" :size="22" fill="currentColor" /><Play
|
||||
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
|
||||
v-else-if="playing"
|
||||
:size="22"
|
||||
fill="currentColor"
|
||||
/><Play
|
||||
v-else
|
||||
:size="22"
|
||||
fill="currentColor"
|
||||
/></span>
|
||||
<small>{{ durationLabel(message.media_duration_ms) }}</small>
|
||||
<small v-if="playbackFailed">{{
|
||||
phone.t('Apps.photos.errors.unsupported')
|
||||
}}</small>
|
||||
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
|
||||
</button>
|
||||
<div v-else class="messages-attachment messages-attachment--gif">
|
||||
<img
|
||||
|
||||
@@ -4,6 +4,11 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import type { UploadReady } from '@/types/media'
|
||||
import { createGameView, type GameView } from '@/utils/gameView'
|
||||
import {
|
||||
bindMediaRecorderError,
|
||||
setBoundedMapEntry,
|
||||
stopMediaRecorder,
|
||||
} from '@/utils/mediaRecorder'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
@@ -12,6 +17,7 @@ type PendingVideo = { blob: Blob; fileName: string }
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const pendingVideos = new Map<string, PendingVideo>()
|
||||
const maxPendingVideos = 3
|
||||
const captureFps = 30
|
||||
const maxCaptureEdge = 720
|
||||
const portraitAspect = 3 / 4
|
||||
@@ -23,12 +29,16 @@ let gameView: GameView | null = null
|
||||
let renderFrameId: number | undefined
|
||||
let lastRenderAt = 0
|
||||
let recorder: MediaRecorder | null = null
|
||||
let recordingStarting = false
|
||||
let stream: MediaStream | null = null
|
||||
let microphoneStream: MediaStream | null = null
|
||||
let chunks: RecordingChunk[] = []
|
||||
let lastChunkAt = 0
|
||||
let lastChunkTimecode: number | null = null
|
||||
let recordingStartedAt = 0
|
||||
let recordingGeneration = 0
|
||||
let flushTimer: number | undefined
|
||||
let removeRecorderErrorListener: (() => void) | null = null
|
||||
|
||||
function captureDimensions(): { height: number; width: number } {
|
||||
return landscape
|
||||
@@ -54,7 +64,11 @@ function ensureGameView(): GameView {
|
||||
if (gameView && !gameView.isLost()) return gameView
|
||||
gameView?.dispose()
|
||||
const dimensions = captureDimensions()
|
||||
gameView = createGameView(canvasRef.value)
|
||||
gameView = createGameView(canvasRef.value, {
|
||||
onContextRestored: () => {
|
||||
if (recorder?.state === 'recording') startRenderLoop()
|
||||
},
|
||||
})
|
||||
gameView.resize(
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
@@ -93,6 +107,7 @@ function resetRecording(): void {
|
||||
chunks = []
|
||||
lastChunkAt = 0
|
||||
lastChunkTimecode = null
|
||||
recordingStartedAt = 0
|
||||
}
|
||||
|
||||
function stopTracks(): void {
|
||||
@@ -103,8 +118,22 @@ function stopTracks(): void {
|
||||
}
|
||||
|
||||
function cleanupRecording(): void {
|
||||
if (recorder && recorder.state !== 'inactive') recorder.stop()
|
||||
recordingGeneration += 1
|
||||
recordingStarting = false
|
||||
const activeRecorder = recorder
|
||||
recorder = null
|
||||
removeRecorderErrorListener?.()
|
||||
removeRecorderErrorListener = null
|
||||
if (activeRecorder) {
|
||||
activeRecorder.ondataavailable = null
|
||||
if (activeRecorder.state !== 'inactive') {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not stop the failed media recorder.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
stopTracks()
|
||||
if (flushTimer !== undefined) window.clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
@@ -114,7 +143,7 @@ function cleanupRecording(): void {
|
||||
}
|
||||
|
||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (recorder) return
|
||||
if (recorder || recordingStarting) return
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
window.postMessage(
|
||||
{
|
||||
@@ -129,7 +158,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (Number.isFinite(configuredBitrate) && configuredBitrate > 0) {
|
||||
bitrateBps = Math.round(configuredBitrate * 1000)
|
||||
}
|
||||
startRenderLoop()
|
||||
try {
|
||||
startRenderLoop()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not start game-view recording.', error)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'capture_failed', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
recordingStarting = true
|
||||
const generation = ++recordingGeneration
|
||||
resetRecording()
|
||||
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
|
||||
if (!videoStream) {
|
||||
@@ -138,15 +182,23 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
}
|
||||
if (data.microphoneEnabled === true) {
|
||||
try {
|
||||
microphoneStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
const acquiredMicrophoneStream =
|
||||
await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
if (generation !== recordingGeneration) {
|
||||
acquiredMicrophoneStream.getTracks().forEach((track) => track.stop())
|
||||
videoStream.getTracks().forEach((track) => track.stop())
|
||||
return
|
||||
}
|
||||
microphoneStream = acquiredMicrophoneStream
|
||||
} catch {
|
||||
videoStream.getTracks().forEach((track) => track.stop())
|
||||
if (generation !== recordingGeneration) return
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
@@ -162,16 +214,51 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
...videoStream.getVideoTracks(),
|
||||
...(microphoneStream?.getAudioTracks() ?? []),
|
||||
])
|
||||
if (generation !== recordingGeneration) {
|
||||
stopTracks()
|
||||
stopRenderLoop()
|
||||
return
|
||||
}
|
||||
const mimeType = [
|
||||
'video/webm;codecs=vp8,opus',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm',
|
||||
].find((type) => MediaRecorder.isTypeSupported(type))
|
||||
recorder = new MediaRecorder(stream, {
|
||||
...(mimeType ? { mimeType } : {}),
|
||||
audioBitsPerSecond: 128_000,
|
||||
videoBitsPerSecond: bitrateBps,
|
||||
})
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, {
|
||||
...(mimeType ? { mimeType } : {}),
|
||||
audioBitsPerSecond: 128_000,
|
||||
videoBitsPerSecond: bitrateBps,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not create the media recorder.', error)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'unsupported', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
const activeRecorder = recorder
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() =>
|
||||
generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Camera] Media recorder failed while recording.', event)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'capture_failed', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
},
|
||||
)
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (!event.data.size) return
|
||||
const now = Date.now()
|
||||
@@ -186,7 +273,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
lastChunkAt = now
|
||||
chunks.push({ blob: event.data, durationMs })
|
||||
}
|
||||
recorder.start()
|
||||
try {
|
||||
recorder.start()
|
||||
} catch (error) {
|
||||
console.error('[Camera] Could not start the media recorder.', error)
|
||||
cleanupRecording()
|
||||
window.postMessage(
|
||||
{
|
||||
data: { error: 'capture_failed', success: false },
|
||||
type: 'camera:recordError',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
return
|
||||
}
|
||||
recordingStartedAt = Date.now()
|
||||
recordingStarting = false
|
||||
flushTimer = window.setInterval(() => {
|
||||
if (recorder?.state === 'recording') recorder.requestData()
|
||||
}, 1000)
|
||||
@@ -196,37 +298,78 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const correlationId = String(data.correlationId ?? '')
|
||||
if (!recorder || recorder.state === 'inactive' || !correlationId) return
|
||||
const generation = recordingGeneration
|
||||
const activeRecorder = recorder
|
||||
postRecordState(false, true)
|
||||
recorder.requestData()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||
recorder.stop()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120))
|
||||
if (flushTimer !== undefined) window.clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
stopTracks()
|
||||
recorder = null
|
||||
stopRenderLoop()
|
||||
const durationMs = chunks.reduce((sum, entry) => sum + entry.durationMs, 0)
|
||||
let blob = new Blob(
|
||||
chunks.map((entry) => entry.blob),
|
||||
{ type: 'video/webm' },
|
||||
)
|
||||
blob = await (
|
||||
fixWebmDuration as unknown as (
|
||||
source: Blob,
|
||||
duration: number,
|
||||
options: { logger: boolean },
|
||||
) => Promise<Blob>
|
||||
)(blob, durationMs, { logger: false })
|
||||
resetRecording()
|
||||
pendingVideos.set(correlationId, {
|
||||
blob,
|
||||
fileName: `camera-${correlationId}.webm`,
|
||||
})
|
||||
await nuiCall('media:requestUpload', {
|
||||
correlationId,
|
||||
mediaType: 'video',
|
||||
})
|
||||
const stopErrorListener = removeRecorderErrorListener
|
||||
stopErrorListener?.()
|
||||
if (removeRecorderErrorListener === stopErrorListener) {
|
||||
removeRecorderErrorListener = null
|
||||
}
|
||||
try {
|
||||
await stopMediaRecorder(activeRecorder)
|
||||
if (generation !== recordingGeneration) return
|
||||
const durationMs = Math.max(
|
||||
chunks.reduce((sum, entry) => sum + entry.durationMs, 0),
|
||||
recordingStartedAt ? Date.now() - recordingStartedAt : 0,
|
||||
)
|
||||
let blob = new Blob(
|
||||
chunks.map((entry) => entry.blob),
|
||||
{ type: 'video/webm' },
|
||||
)
|
||||
blob = await (
|
||||
fixWebmDuration as unknown as (
|
||||
source: Blob,
|
||||
duration: number,
|
||||
options: { logger: boolean },
|
||||
) => Promise<Blob>
|
||||
)(blob, durationMs, { logger: false })
|
||||
if (generation !== recordingGeneration) return
|
||||
setBoundedMapEntry(
|
||||
pendingVideos,
|
||||
correlationId,
|
||||
{ blob, fileName: `camera-${correlationId}.webm` },
|
||||
maxPendingVideos,
|
||||
)
|
||||
const response = await nuiCall('media:requestUpload', {
|
||||
correlationId,
|
||||
mediaType: 'video',
|
||||
})
|
||||
if (generation !== recordingGeneration) return
|
||||
if (!response.success) {
|
||||
pendingVideos.delete(correlationId)
|
||||
window.postMessage(
|
||||
{
|
||||
data: { correlationId, error: 'request_failed', success: false },
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === recordingGeneration) {
|
||||
console.error('[Camera] Could not finalize the video recording.', error)
|
||||
pendingVideos.delete(correlationId)
|
||||
window.postMessage(
|
||||
{
|
||||
data: { correlationId, error: 'capture_failed', success: false },
|
||||
type: 'media:uploadResult',
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (generation === recordingGeneration || recorder === activeRecorder) {
|
||||
if (recorder === activeRecorder) {
|
||||
recorder = null
|
||||
}
|
||||
stopTracks()
|
||||
stopRenderLoop()
|
||||
resetRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderFrames(view: GameView, count: number): Promise<void> {
|
||||
@@ -381,6 +524,9 @@ function onMessage(event: MessageEvent): void {
|
||||
}
|
||||
} else if (message.type === 'media:uploadReady') {
|
||||
void uploadReady(message.data as UploadReady)
|
||||
} else if (message.type === 'media:uploadResult') {
|
||||
const correlationId = String(message.data?.correlationId ?? '')
|
||||
if (correlationId) pendingVideos.delete(correlationId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user