mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { gameViewGeometry } from '@/utils/gameView'
|
||||
import { createGameView, gameViewGeometry } from '@/utils/gameView'
|
||||
|
||||
describe('gameViewGeometry', () => {
|
||||
it('center-crops a widescreen game view for 3:4 portrait output', () => {
|
||||
@@ -60,3 +60,92 @@ describe('gameViewGeometry', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createGameView', () => {
|
||||
it('recreates graphics resources and resumes after context restoration', () => {
|
||||
const gl = {
|
||||
ARRAY_BUFFER: 1,
|
||||
CLAMP_TO_EDGE: 2,
|
||||
COLOR_BUFFER_BIT: 4,
|
||||
COMPILE_STATUS: 5,
|
||||
DYNAMIC_DRAW: 6,
|
||||
FLOAT: 7,
|
||||
FRAGMENT_SHADER: 8,
|
||||
LINK_STATUS: 9,
|
||||
MIRRORED_REPEAT: 10,
|
||||
NEAREST: 11,
|
||||
REPEAT: 12,
|
||||
RGBA: 13,
|
||||
STATIC_DRAW: 14,
|
||||
TEXTURE_2D: 15,
|
||||
TEXTURE_MAG_FILTER: 16,
|
||||
TEXTURE_MIN_FILTER: 17,
|
||||
TEXTURE_WRAP_S: 18,
|
||||
TEXTURE_WRAP_T: 19,
|
||||
TRIANGLE_STRIP: 20,
|
||||
UNSIGNED_BYTE: 21,
|
||||
VERTEX_SHADER: 22,
|
||||
attachShader: vi.fn(),
|
||||
bindBuffer: vi.fn(),
|
||||
bindTexture: vi.fn(),
|
||||
bufferData: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearColor: vi.fn(),
|
||||
compileShader: vi.fn(),
|
||||
createBuffer: vi.fn(() => ({})),
|
||||
createProgram: vi.fn(() => ({})),
|
||||
createShader: vi.fn(() => ({})),
|
||||
createTexture: vi.fn(() => ({})),
|
||||
deleteBuffer: vi.fn(),
|
||||
deleteProgram: vi.fn(),
|
||||
deleteShader: vi.fn(),
|
||||
deleteTexture: vi.fn(),
|
||||
drawArrays: vi.fn(),
|
||||
enableVertexAttribArray: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
getAttribLocation: vi.fn((_program, name: string) =>
|
||||
name === 'a_position' ? 0 : 1,
|
||||
),
|
||||
getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
|
||||
getProgramInfoLog: vi.fn(() => ''),
|
||||
getProgramParameter: vi.fn(() => true),
|
||||
getShaderInfoLog: vi.fn(() => ''),
|
||||
getShaderParameter: vi.fn(() => true),
|
||||
getUniformLocation: vi.fn(() => ({})),
|
||||
linkProgram: vi.fn(),
|
||||
shaderSource: vi.fn(),
|
||||
texImage2D: vi.fn(),
|
||||
texParameterf: vi.fn(),
|
||||
uniform1i: vi.fn(),
|
||||
useProgram: vi.fn(),
|
||||
vertexAttribPointer: vi.fn(),
|
||||
viewport: vi.fn(),
|
||||
}
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
getContext: () => gl,
|
||||
height: 0,
|
||||
width: 0,
|
||||
}) as unknown as HTMLCanvasElement
|
||||
const restored = vi.fn()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
vi.spyOn(console, 'info').mockImplementation(() => undefined)
|
||||
const view = createGameView(canvas, { onContextRestored: restored })
|
||||
view.resize(540, 720, 1920, 1080, 2)
|
||||
|
||||
const lost = new Event('webglcontextlost', { cancelable: true })
|
||||
canvas.dispatchEvent(lost)
|
||||
expect(lost.defaultPrevented).toBe(true)
|
||||
expect(view.isLost()).toBe(true)
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextrestored'))
|
||||
expect(view.isLost()).toBe(false)
|
||||
expect(restored).toHaveBeenCalledOnce()
|
||||
expect(gl.createProgram).toHaveBeenCalledTimes(2)
|
||||
expect(canvas.width).toBe(540)
|
||||
expect(canvas.height).toBe(720)
|
||||
|
||||
view.render()
|
||||
expect(gl.drawArrays).toHaveBeenCalledOnce()
|
||||
view.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
+177
-78
@@ -31,6 +31,8 @@ export interface GameView {
|
||||
}
|
||||
|
||||
export interface GameViewOptions {
|
||||
onContextLost?: () => void
|
||||
onContextRestored?: () => void
|
||||
preserveDrawingBuffer?: boolean
|
||||
}
|
||||
|
||||
@@ -113,80 +115,180 @@ export function createGameView(
|
||||
|
||||
let lost = false
|
||||
let disposed = false
|
||||
let program: WebGLProgram | null = null
|
||||
let positionBuffer: WebGLBuffer | null = null
|
||||
let texcoordBuffer: WebGLBuffer | null = null
|
||||
let texture: WebGLTexture | null = null
|
||||
let lastSize: {
|
||||
height: number
|
||||
sourceHeight: number
|
||||
sourceWidth: number
|
||||
width: number
|
||||
zoom: number
|
||||
} | null = null
|
||||
|
||||
const releaseResources = (): void => {
|
||||
if (positionBuffer) gl.deleteBuffer(positionBuffer)
|
||||
if (texcoordBuffer) gl.deleteBuffer(texcoordBuffer)
|
||||
if (texture) gl.deleteTexture(texture)
|
||||
if (program) gl.deleteProgram(program)
|
||||
positionBuffer = null
|
||||
texcoordBuffer = null
|
||||
texture = null
|
||||
program = null
|
||||
}
|
||||
|
||||
const initializeResources = (): void => {
|
||||
releaseResources()
|
||||
const nextProgram = gl.createProgram()
|
||||
if (!nextProgram) throw new Error('game_view_program_unavailable')
|
||||
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
|
||||
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
|
||||
gl.attachShader(nextProgram, vertexShader)
|
||||
gl.attachShader(nextProgram, fragmentShader)
|
||||
gl.linkProgram(nextProgram)
|
||||
gl.deleteShader(vertexShader)
|
||||
gl.deleteShader(fragmentShader)
|
||||
if (!gl.getProgramParameter(nextProgram, gl.LINK_STATUS)) {
|
||||
const error = gl.getProgramInfoLog(nextProgram)
|
||||
gl.deleteProgram(nextProgram)
|
||||
throw new Error(error || 'game_view_program_failed')
|
||||
}
|
||||
gl.useProgram(nextProgram)
|
||||
|
||||
const positionLocation = gl.getAttribLocation(nextProgram, 'a_position')
|
||||
const texcoordLocation = gl.getAttribLocation(nextProgram, 'a_texcoord')
|
||||
if (positionLocation < 0 || texcoordLocation < 0) {
|
||||
gl.deleteProgram(nextProgram)
|
||||
throw new Error('game_view_attributes_unavailable')
|
||||
}
|
||||
|
||||
const nextPositionBuffer = gl.createBuffer()
|
||||
const nextTexcoordBuffer = gl.createBuffer()
|
||||
const nextTexture = gl.createTexture()
|
||||
if (!nextPositionBuffer || !nextTexcoordBuffer || !nextTexture) {
|
||||
if (nextPositionBuffer) gl.deleteBuffer(nextPositionBuffer)
|
||||
if (nextTexcoordBuffer) gl.deleteBuffer(nextTexcoordBuffer)
|
||||
if (nextTexture) gl.deleteTexture(nextTexture)
|
||||
gl.deleteProgram(nextProgram)
|
||||
throw new Error('game_view_resources_unavailable')
|
||||
}
|
||||
|
||||
program = nextProgram
|
||||
positionBuffer = nextPositionBuffer
|
||||
texcoordBuffer = nextTexcoordBuffer
|
||||
texture = nextTexture
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.enableVertexAttribArray(positionLocation)
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
gl.clearColor(0, 0, 0, 1)
|
||||
}
|
||||
|
||||
const applySize = (): void => {
|
||||
if (!lastSize || !positionBuffer || !texcoordBuffer) return
|
||||
const geometry = gameViewGeometry(
|
||||
lastSize.sourceWidth,
|
||||
lastSize.sourceHeight,
|
||||
lastSize.width,
|
||||
lastSize.height,
|
||||
lastSize.zoom,
|
||||
)
|
||||
canvas.width = lastSize.width
|
||||
canvas.height = lastSize.height
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
geometry.textureCoordinates,
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.viewport(0, 0, lastSize.width, lastSize.height)
|
||||
}
|
||||
|
||||
const onContextLost = (event: Event) => {
|
||||
event.preventDefault()
|
||||
lost = true
|
||||
console.error('[Camera] Game-view WebGL context lost.')
|
||||
options.onContextLost?.()
|
||||
}
|
||||
const onContextRestored = () => {
|
||||
if (disposed) return
|
||||
try {
|
||||
initializeResources()
|
||||
lost = false
|
||||
applySize()
|
||||
console.info('[Camera] Game-view WebGL context restored.')
|
||||
options.onContextRestored?.()
|
||||
} catch (error) {
|
||||
lost = true
|
||||
console.error('[Camera] Could not restore the game-view WebGL context.', error)
|
||||
}
|
||||
}
|
||||
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),
|
||||
canvas.addEventListener(
|
||||
'webglcontextrestored',
|
||||
onContextRestored as EventListener,
|
||||
false,
|
||||
)
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
|
||||
try {
|
||||
initializeResources()
|
||||
} catch (error) {
|
||||
canvas.removeEventListener(
|
||||
'webglcontextlost',
|
||||
onContextLost as EventListener,
|
||||
false,
|
||||
)
|
||||
canvas.removeEventListener(
|
||||
'webglcontextrestored',
|
||||
onContextRestored as EventListener,
|
||||
false,
|
||||
)
|
||||
releaseResources()
|
||||
throw error
|
||||
}
|
||||
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.DYNAMIC_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)
|
||||
gl.clearColor(0, 0, 0, 1)
|
||||
|
||||
return {
|
||||
canvas,
|
||||
@@ -198,11 +300,18 @@ export function createGameView(
|
||||
onContextLost as EventListener,
|
||||
false,
|
||||
)
|
||||
canvas.removeEventListener(
|
||||
'webglcontextrestored',
|
||||
onContextRestored as EventListener,
|
||||
false,
|
||||
)
|
||||
if (!lost) releaseResources()
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
},
|
||||
isLost: () => lost,
|
||||
render() {
|
||||
if (disposed || lost) return
|
||||
if (disposed || lost || !program) return
|
||||
gl.useProgram(program)
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
gl.finish()
|
||||
@@ -214,25 +323,15 @@ export function createGameView(
|
||||
sourceHeight = window.innerHeight,
|
||||
zoom = 1,
|
||||
) {
|
||||
if (disposed || lost) return
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const geometry = gameViewGeometry(
|
||||
lastSize = {
|
||||
height,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
geometry.textureCoordinates,
|
||||
gl.DYNAMIC_DRAW,
|
||||
)
|
||||
gl.viewport(0, 0, width, height)
|
||||
}
|
||||
if (disposed || lost) return
|
||||
applySize()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
bindMediaRecorderError,
|
||||
setBoundedMapEntry,
|
||||
stopMediaRecorder,
|
||||
} from '@/utils/mediaRecorder'
|
||||
|
||||
class FakeRecorder extends EventTarget {
|
||||
state: RecordingState = 'recording'
|
||||
stop = vi.fn(() => {
|
||||
this.state = 'inactive'
|
||||
this.dispatchEvent(new Event('stop'))
|
||||
})
|
||||
}
|
||||
|
||||
describe('media recorder lifecycle', () => {
|
||||
it('resolves from the recorder stop event', async () => {
|
||||
const recorder = new FakeRecorder()
|
||||
|
||||
await stopMediaRecorder(recorder as unknown as MediaRecorder)
|
||||
|
||||
expect(recorder.stop).toHaveBeenCalledOnce()
|
||||
expect(recorder.state).toBe('inactive')
|
||||
})
|
||||
|
||||
it('runs recorder error cleanup only for the current generation', () => {
|
||||
const staleRecorder = new FakeRecorder()
|
||||
const currentRecorder = new FakeRecorder()
|
||||
const cleanup = vi.fn()
|
||||
let generation = 1
|
||||
const unbindStale = bindMediaRecorderError(
|
||||
staleRecorder as unknown as MediaRecorder,
|
||||
() => generation === 1,
|
||||
cleanup,
|
||||
)
|
||||
|
||||
generation = 2
|
||||
staleRecorder.dispatchEvent(new Event('error'))
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
unbindStale()
|
||||
|
||||
bindMediaRecorderError(
|
||||
currentRecorder as unknown as MediaRecorder,
|
||||
() => generation === 2,
|
||||
cleanup,
|
||||
)
|
||||
currentRecorder.dispatchEvent(new Event('error'))
|
||||
currentRecorder.dispatchEvent(new Event('error'))
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps pending recording buffers bounded and evicts the oldest', () => {
|
||||
const pending = new Map<string, number>()
|
||||
|
||||
setBoundedMapEntry(pending, 'first', 1, 2)
|
||||
setBoundedMapEntry(pending, 'second', 2, 2)
|
||||
setBoundedMapEntry(pending, 'third', 3, 2)
|
||||
|
||||
expect([...pending.entries()]).toEqual([
|
||||
['second', 2],
|
||||
['third', 3],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
export function bindMediaRecorderError(
|
||||
recorder: MediaRecorder,
|
||||
isCurrent: () => boolean,
|
||||
onError: (event: Event) => void,
|
||||
): () => void {
|
||||
let bound = true
|
||||
const handleError = (event: Event): void => {
|
||||
if (!bound || !isCurrent()) return
|
||||
bound = false
|
||||
recorder.removeEventListener('error', handleError)
|
||||
onError(event)
|
||||
}
|
||||
recorder.addEventListener('error', handleError)
|
||||
return () => {
|
||||
if (!bound) return
|
||||
bound = false
|
||||
recorder.removeEventListener('error', handleError)
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopMediaRecorder(recorder: MediaRecorder): Promise<void> {
|
||||
if (recorder.state === 'inactive') return
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
recorder.removeEventListener('stop', onStop)
|
||||
recorder.removeEventListener('error', onError)
|
||||
}
|
||||
const onStop = (): void => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = (): void => {
|
||||
cleanup()
|
||||
reject(new Error('media_recorder_stop_failed'))
|
||||
}
|
||||
|
||||
recorder.addEventListener('stop', onStop, { once: true })
|
||||
recorder.addEventListener('error', onError, { once: true })
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function setBoundedMapEntry<Key, Value>(
|
||||
entries: Map<Key, Value>,
|
||||
key: Key,
|
||||
value: Value,
|
||||
maximumSize: number,
|
||||
): void {
|
||||
entries.delete(key)
|
||||
entries.set(key, value)
|
||||
while (entries.size > Math.max(0, maximumSize)) {
|
||||
const oldest = entries.keys().next()
|
||||
if (oldest.done) break
|
||||
entries.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ const elapsed = ref('00:00')
|
||||
const captures = ref<CaptureItem[]>([])
|
||||
const latestMedia = ref<PhoneMedia | null>(null)
|
||||
const gameCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const gameViewUnavailable = ref(false)
|
||||
const noticeText = ref('')
|
||||
const videoBitrateKbps = ref(1500)
|
||||
let shutterTimer: number | undefined
|
||||
@@ -291,10 +292,26 @@ function resizeGameView(entry?: ResizeObserverEntry): void {
|
||||
|
||||
function startGameView(): void {
|
||||
if (isDevelopment || !gameCanvas.value) return
|
||||
gameView = createGameView(gameCanvas.value)
|
||||
try {
|
||||
gameView = createGameView(gameCanvas.value, {
|
||||
onContextRestored: () => {
|
||||
resizeGameView()
|
||||
startGameViewRenderLoop()
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
gameViewUnavailable.value = true
|
||||
console.error('[Camera] Game-view rendering is unavailable.', error)
|
||||
return
|
||||
}
|
||||
resizeObserver = new ResizeObserver((entries) => resizeGameView(entries[0]))
|
||||
resizeObserver.observe(gameCanvas.value)
|
||||
resizeGameView()
|
||||
startGameViewRenderLoop()
|
||||
}
|
||||
|
||||
function startGameViewRenderLoop(): void {
|
||||
if (renderFrameId !== undefined) return
|
||||
const render = () => {
|
||||
if (!gameView || gameView.isLost()) {
|
||||
renderFrameId = undefined
|
||||
@@ -434,7 +451,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="camera-viewport" @wheel.prevent="zoomWithWheel">
|
||||
<canvas
|
||||
v-if="!isDevelopment"
|
||||
v-if="!isDevelopment && !gameViewUnavailable"
|
||||
ref="gameCanvas"
|
||||
class="camera-game-view"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ShieldAlert,
|
||||
Send,
|
||||
Share2,
|
||||
TriangleAlert,
|
||||
UserRound,
|
||||
Video,
|
||||
X,
|
||||
@@ -116,6 +117,7 @@ const publishing = ref(false)
|
||||
const feedback = ref('')
|
||||
const likedPulseId = ref<string | null>(null)
|
||||
const reactionPulse = ref<{ id: string; kind: 'like' | 'save' } | null>(null)
|
||||
const playbackFailedIds = ref(new Set<string>())
|
||||
const profileDraft = ref({
|
||||
accountType: 'person',
|
||||
bio: '',
|
||||
@@ -250,11 +252,66 @@ async function confirmLogout(): Promise<void> {
|
||||
}
|
||||
|
||||
function setVideoElement(id: string, element: unknown): void {
|
||||
if (element instanceof HTMLVideoElement) videoElements.set(id, element)
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
videoElements.set(id, element)
|
||||
return
|
||||
}
|
||||
videoElements.delete(id)
|
||||
setPlaybackFailed(id, false)
|
||||
}
|
||||
|
||||
function setMusicElement(id: string, element: unknown): void {
|
||||
if (element instanceof HTMLAudioElement) musicElements.set(id, element)
|
||||
if (element instanceof HTMLAudioElement) {
|
||||
musicElements.set(id, element)
|
||||
return
|
||||
}
|
||||
musicElements.delete(id)
|
||||
}
|
||||
|
||||
function setPlaybackFailed(id: string, failed: boolean): void {
|
||||
const next = new Set(playbackFailedIds.value)
|
||||
if (failed) next.add(id)
|
||||
else next.delete(id)
|
||||
playbackFailedIds.value = next
|
||||
}
|
||||
|
||||
async function playFeedVideo(
|
||||
id: string,
|
||||
element: HTMLVideoElement,
|
||||
showNotice = false,
|
||||
): Promise<boolean> {
|
||||
setPlaybackFailed(id, false)
|
||||
try {
|
||||
await element.play()
|
||||
} catch (error) {
|
||||
setPlaybackFailed(id, true)
|
||||
console.error(`[FlipTok] Could not play video ${id}.`, error)
|
||||
if (showNotice) notify(t('errors.video_not_found'))
|
||||
return false
|
||||
}
|
||||
|
||||
const music = musicElements.get(id)
|
||||
if (music) {
|
||||
try {
|
||||
await music.play()
|
||||
} catch (error) {
|
||||
console.error(`[FlipTok] Could not play music for video ${id}.`, error)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleFeedVideoError(id: string, event: Event): void {
|
||||
setPlaybackFailed(id, true)
|
||||
const code = (event.currentTarget as HTMLVideoElement).error?.code
|
||||
console.error(
|
||||
`[FlipTok] Video ${id} could not be decoded or loaded${code ? ` (media error ${code})` : ''}.`,
|
||||
)
|
||||
}
|
||||
|
||||
function retryPlayback(video: FlipTokVideo): void {
|
||||
const element = videoElements.get(video.id)
|
||||
if (element) void playFeedVideo(video.id, element, true)
|
||||
}
|
||||
|
||||
function chooseMusicTrack(trackId: string): void {
|
||||
@@ -305,10 +362,8 @@ function observeVideos(): void {
|
||||
otherAudio?.pause()
|
||||
}
|
||||
})
|
||||
void video.play().catch(() => undefined)
|
||||
const id = video.dataset.id
|
||||
const music = id ? musicElements.get(id) : undefined
|
||||
if (music) void music.play().catch(() => undefined)
|
||||
if (id) void playFeedVideo(id, video)
|
||||
if (id) void nuiCall('fliptok:view', { id })
|
||||
} else {
|
||||
video.pause()
|
||||
@@ -327,8 +382,7 @@ function togglePlayback(video: FlipTokVideo): void {
|
||||
if (!element) return
|
||||
const music = musicElements.get(video.id)
|
||||
if (element.paused) {
|
||||
void element.play()
|
||||
if (music) void music.play().catch(() => undefined)
|
||||
void playFeedVideo(video.id, element, true)
|
||||
} else {
|
||||
element.pause()
|
||||
music?.pause()
|
||||
@@ -339,6 +393,7 @@ function prepareFeedVideo(
|
||||
video: FlipTokVideo,
|
||||
element: HTMLVideoElement,
|
||||
): void {
|
||||
setPlaybackFailed(video.id, false)
|
||||
element.volume = (Number(video.original_volume) || 0) / 100
|
||||
const start = Math.min(
|
||||
(Number(video.trim_start_ms) || 0) / 1000,
|
||||
@@ -943,6 +998,7 @@ onBeforeUnmount(() => {
|
||||
@timeupdate="
|
||||
enforceVideoTrim(video, $event.target as HTMLVideoElement)
|
||||
"
|
||||
@error="handleFeedVideoError(video.id, $event)"
|
||||
/>
|
||||
<audio
|
||||
v-if="video.music_url"
|
||||
@@ -959,6 +1015,17 @@ onBeforeUnmount(() => {
|
||||
@click="handleVideoClick(video)"
|
||||
@dblclick.prevent="handleVideoDoubleClick(video)"
|
||||
/>
|
||||
<button
|
||||
v-if="playbackFailedIds.has(video.id)"
|
||||
type="button"
|
||||
class="video-playback-fallback"
|
||||
:aria-label="`${t('errors.video_not_found')} ${phone.t('Common.start')}`"
|
||||
@click.stop="retryPlayback(video)"
|
||||
>
|
||||
<TriangleAlert />
|
||||
<strong>{{ t('errors.video_not_found') }}</strong>
|
||||
<span>{{ phone.t('Common.start') }}</span>
|
||||
</button>
|
||||
<Transition name="double-like">
|
||||
<Heart
|
||||
v-if="likedPulseId === video.id"
|
||||
@@ -1932,6 +1999,36 @@ onBeforeUnmount(() => {
|
||||
rgba(0, 0, 0, 0.74)
|
||||
);
|
||||
}
|
||||
.video-playback-fallback {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(230px, 72%);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid rgb(255 255 255 / 24%);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background: rgb(18 18 20 / 88%);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.video-playback-fallback svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #ff9f0a;
|
||||
}
|
||||
.video-playback-fallback strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
.video-playback-fallback span {
|
||||
color: #64a8ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.video-copy {
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
|
||||
@@ -84,10 +84,13 @@ const imageZoom = ref(1)
|
||||
const imagePan = ref({ x: 0, y: 0 })
|
||||
const landscapeViewer = ref(false)
|
||||
const dragging = ref(false)
|
||||
const videoPlaybackError = ref(false)
|
||||
const dragStart = ref({ panX: 0, panY: 0, x: 0, y: 0 })
|
||||
let observer: IntersectionObserver | null = null
|
||||
let toastTimer: number | undefined
|
||||
let pendingDeleteCorrelation = ''
|
||||
let dragTarget: HTMLElement | null = null
|
||||
let dragPointerId: number | null = null
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
cursor:
|
||||
@@ -227,6 +230,7 @@ function openMedia(entry: PhoneMedia): void {
|
||||
landscapeViewer.value = false
|
||||
phone.setCameraLandscape(false)
|
||||
selected.value = entry
|
||||
videoPlaybackError.value = false
|
||||
imageZoom.value = 1
|
||||
imagePan.value = { x: 0, y: 0 }
|
||||
}
|
||||
@@ -254,6 +258,7 @@ function closeMedia(): void {
|
||||
landscapeViewer.value = false
|
||||
phone.setCameraLandscape(false)
|
||||
selected.value = null
|
||||
videoPlaybackError.value = false
|
||||
deleteDialogOpened.value = false
|
||||
stopDragging()
|
||||
}
|
||||
@@ -295,6 +300,9 @@ function startDragging(event: PointerEvent): void {
|
||||
setZoom(2)
|
||||
return
|
||||
}
|
||||
dragTarget = event.currentTarget as HTMLElement
|
||||
dragPointerId = event.pointerId
|
||||
dragTarget.setPointerCapture(event.pointerId)
|
||||
dragging.value = true
|
||||
dragStart.value = {
|
||||
panX: imagePan.value.x,
|
||||
@@ -302,8 +310,6 @@ function startDragging(event: PointerEvent): void {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
}
|
||||
window.addEventListener('pointermove', moveImage)
|
||||
window.addEventListener('pointerup', stopDragging)
|
||||
}
|
||||
|
||||
function moveImage(event: PointerEvent): void {
|
||||
@@ -316,8 +322,44 @@ function moveImage(event: PointerEvent): void {
|
||||
|
||||
function stopDragging(): void {
|
||||
dragging.value = false
|
||||
window.removeEventListener('pointermove', moveImage)
|
||||
window.removeEventListener('pointerup', stopDragging)
|
||||
if (
|
||||
dragTarget &&
|
||||
dragPointerId !== null &&
|
||||
dragTarget.hasPointerCapture(dragPointerId)
|
||||
) {
|
||||
dragTarget.releasePointerCapture(dragPointerId)
|
||||
}
|
||||
dragTarget = null
|
||||
dragPointerId = null
|
||||
}
|
||||
|
||||
function moveImageWithKeyboard(event: KeyboardEvent): void {
|
||||
if (imageZoom.value <= 1) return
|
||||
const step = event.shiftKey ? 48 : 24
|
||||
const offsets: Partial<Record<string, { x: number; y: number }>> = {
|
||||
ArrowDown: { x: 0, y: -step },
|
||||
ArrowLeft: { x: step, y: 0 },
|
||||
ArrowRight: { x: -step, y: 0 },
|
||||
ArrowUp: { x: 0, y: step },
|
||||
}
|
||||
const offset = offsets[event.key]
|
||||
if (!offset) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
imagePan.value = {
|
||||
x: imagePan.value.x + offset.x,
|
||||
y: imagePan.value.y + offset.y,
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeVideo(event: Event): Promise<void> {
|
||||
orientToMedia(event)
|
||||
videoPlaybackError.value = false
|
||||
try {
|
||||
await (event.currentTarget as HTMLVideoElement).play()
|
||||
} catch {
|
||||
// The native controls remain visible when embedded CEF blocks autoplay.
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(): Promise<void> {
|
||||
@@ -569,18 +611,33 @@ onBeforeUnmount(() => {
|
||||
:alt="phone.t('Apps.photos.photoAlt')"
|
||||
:style="imageStyle"
|
||||
draggable="false"
|
||||
tabindex="0"
|
||||
@load="orientToMedia"
|
||||
@pointerdown="startDragging"
|
||||
@pointermove="moveImage"
|
||||
@pointerup="stopDragging"
|
||||
@pointercancel="stopDragging"
|
||||
@lostpointercapture="stopDragging"
|
||||
@keydown="moveImageWithKeyboard"
|
||||
@dblclick="setZoom(imageZoom === 1 ? 2 : 1)"
|
||||
/>
|
||||
<video
|
||||
v-else
|
||||
:src="selected.url"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
@loadedmetadata="orientToMedia"
|
||||
@loadedmetadata="initializeVideo"
|
||||
@error="videoPlaybackError = true"
|
||||
></video>
|
||||
<k-block
|
||||
v-if="selected.mediaType === 'video' && videoPlaybackError"
|
||||
strong
|
||||
inset
|
||||
class="gallery-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ phone.t('Apps.photos.errors.unsupported') }}
|
||||
</k-block>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -776,6 +833,8 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 720px;
|
||||
height: 368px;
|
||||
width: 100cqh;
|
||||
height: 100cqw;
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
|
||||
@@ -4091,7 +4091,6 @@ function easyShareHistoryForScenario(testScenario) {
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', (request, response) => {
|
||||
console.log(`[NUI] ${request.params.endpoint}`, request.body)
|
||||
const endpoint = request.params.endpoint
|
||||
const testScenario = String(request.body._testScenario ?? '')
|
||||
if (lifecycleEndpoints.has(endpoint)) {
|
||||
@@ -7235,7 +7234,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
: messageType === 'gif'
|
||||
? 'image/gif'
|
||||
: messageType === 'video'
|
||||
? 'video/mp4'
|
||||
? 'video/webm'
|
||||
: null,
|
||||
media_payload:
|
||||
messageType === 'voice'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
Config.Media = {
|
||||
GiphyApiKey = "",
|
||||
GiphyApiKeyConvar = "sky_phone_giphy_api_key",
|
||||
GifPageSize = 24,
|
||||
GifRating = "pg-13",
|
||||
UrlMaxLength = 2048,
|
||||
AllowedGifHosts = { "giphy.com" },
|
||||
FiveManage = {
|
||||
ApiKey = "e4UZ9y39JfkxHoZMAgRUVK6KMQsNCKPJ", -- Dashboard -> Tokens -> create a token with Media access.
|
||||
ApiKeyConvar = "sky_phone_fivemanage_api_key",
|
||||
BaseUrl = "https://api.fivemanage.com/api/v3/file",
|
||||
RequestTimeoutMs = 10000,
|
||||
UploadTimeoutMs = 25000,
|
||||
|
||||
@@ -538,12 +538,16 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
|
||||
media_waveform = voice.waveform
|
||||
elseif message_type == "image" or message_type == "video" then
|
||||
local media_type = message_type == "image" and "photo" or "video"
|
||||
local media_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.mediaAssetId, media_type)
|
||||
local media_url, media_error, resolved_mime = SkyPhoneMedia.ResolveOwnedMedia(
|
||||
source,
|
||||
data.mediaAssetId,
|
||||
media_type
|
||||
)
|
||||
if not media_url then
|
||||
return { success = false, error = media_error }
|
||||
end
|
||||
media_payload = media_url
|
||||
media_mime = message_type == "image" and "image/jpeg" or "video/mp4"
|
||||
media_mime = resolved_mime
|
||||
elseif message_type == "share" then
|
||||
local share
|
||||
local share_error
|
||||
|
||||
@@ -377,6 +377,7 @@ local schema = {
|
||||
{ name = "url", type = "TEXT NOT NULL" },
|
||||
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
|
||||
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
|
||||
{ name = "mime_type", type = "VARCHAR(120) NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
@@ -2524,6 +2525,37 @@ Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_darkchat_messages`
|
||||
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_media`
|
||||
SET `mime_type` = CASE
|
||||
WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webm' THEN 'video/webm'
|
||||
WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.mp4' THEN 'video/mp4'
|
||||
WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.png' THEN 'image/png'
|
||||
WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webp' THEN 'image/webp'
|
||||
WHEN `media_type` = 'photo' AND (
|
||||
LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpg'
|
||||
OR LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpeg'
|
||||
) THEN 'image/jpeg'
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE `mime_type` IS NULL OR `mime_type` = ''
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_sms_messages` message
|
||||
INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload`
|
||||
SET message.`media_mime` = media.`mime_type`
|
||||
WHERE message.`message_type` = 'video'
|
||||
AND media.`mime_type` IN ('video/webm', 'video/mp4')
|
||||
AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`)
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_darkchat_messages` message
|
||||
INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload`
|
||||
SET message.`media_mime` = media.`mime_type`
|
||||
WHERE message.`message_type` = 'video'
|
||||
AND media.`mime_type` IN ('video/webm', 'video/mp4')
|
||||
AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`)
|
||||
]], {})
|
||||
Bridge.Database.Query([[
|
||||
ALTER TABLE `sky_phone_marketplace_images`
|
||||
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
|
||||
|
||||
@@ -3,14 +3,32 @@ SkyPhoneMedia = {}
|
||||
|
||||
local pending_uploads = {}
|
||||
local pending_deletes = {}
|
||||
local allowed_remote_mimes = {
|
||||
photo = {
|
||||
["image/jpeg"] = true,
|
||||
["image/png"] = true,
|
||||
["image/webp"] = true,
|
||||
},
|
||||
video = {
|
||||
["video/mp4"] = true,
|
||||
["video/webm"] = true,
|
||||
},
|
||||
}
|
||||
|
||||
local function media_config()
|
||||
return Config.Media.FiveManage
|
||||
end
|
||||
|
||||
local function media_api_key()
|
||||
local convar = media_config().ApiKeyConvar
|
||||
if type(convar) ~= "string" or convar == "" then
|
||||
return ""
|
||||
end
|
||||
return GetConvar(convar, "")
|
||||
end
|
||||
|
||||
local function api_configured()
|
||||
local api_key = media_config().ApiKey
|
||||
return type(api_key) == "string" and api_key ~= "" and api_key ~= "YOUR_API_TOKEN"
|
||||
return media_api_key() ~= ""
|
||||
end
|
||||
|
||||
local function http_request(url, method, body, headers, timeout_ms)
|
||||
@@ -63,7 +81,7 @@ local function request_presigned_url()
|
||||
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = config.ApiKey },
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
local data, response_error = decode_response(response)
|
||||
@@ -86,7 +104,7 @@ local function get_remote_file(remote_id)
|
||||
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = config.ApiKey },
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
return decode_response(response)
|
||||
@@ -101,7 +119,7 @@ local function delete_remote_file(remote_id)
|
||||
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
|
||||
"DELETE",
|
||||
"",
|
||||
{ ["Authorization"] = config.ApiKey },
|
||||
{ ["Authorization"] = media_api_key() },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
@@ -153,7 +171,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
||||
params[#params + 1] = value
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `url`, `media_type` FROM `sky_phone_media`
|
||||
SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
|
||||
WHERE `id` = ? AND %s
|
||||
LIMIT 1
|
||||
]]):format(condition), params)
|
||||
@@ -172,7 +190,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
|
||||
)
|
||||
return nil, "invalid_attachment"
|
||||
end
|
||||
return media.url
|
||||
return media.url, nil, media.mime_type
|
||||
end
|
||||
|
||||
local function upload_result(source, correlation_id, success, error_code, media)
|
||||
@@ -224,18 +242,29 @@ local function verify_remote_upload(state, remote_id, uploaded_url)
|
||||
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local remote_type = tostring(remote.type or remote.mimeType or ""):lower()
|
||||
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
|
||||
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
|
||||
if remote_mime == "" and allowed_remote_mimes[state.media_type][remote_type] then
|
||||
remote_mime = remote_type
|
||||
end
|
||||
if remote_type == "" then
|
||||
remote_type = remote_mime
|
||||
end
|
||||
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
|
||||
return nil, "invalid_media_type"
|
||||
end
|
||||
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
|
||||
return nil, "invalid_media_type"
|
||||
end
|
||||
if remote_mime ~= "" and not allowed_remote_mimes[state.media_type][remote_mime] then
|
||||
return nil, "invalid_media_type"
|
||||
end
|
||||
local metadata = parse_metadata(remote.metadata)
|
||||
if not metadata or metadata.captureToken ~= state.capture_token then
|
||||
return nil, "invalid_upload_token"
|
||||
end
|
||||
return {
|
||||
mime_type = allowed_remote_mimes[state.media_type][remote_mime] and remote_mime or state.mime_type,
|
||||
remote_id = remote_id,
|
||||
url = remote.url or uploaded_url,
|
||||
}
|
||||
@@ -255,7 +284,7 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
|
||||
if not owner then
|
||||
return error_response
|
||||
end
|
||||
data = data or {}
|
||||
data = type(data) == "table" and data or {}
|
||||
local limit = math.max(1, math.min(math.floor(tonumber(data.limit) or Config.Media.PageSize), 100))
|
||||
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
|
||||
local media_type = data.mediaType
|
||||
@@ -342,7 +371,7 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
|
||||
if #query > 60 or offset < 0 or offset > 500 then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local api_key = Config.Media.GiphyApiKey
|
||||
local api_key = GetConvar(Config.Media.GiphyApiKeyConvar, "")
|
||||
if api_key == "" then
|
||||
return { success = false, error = "gif_provider_unconfigured" }
|
||||
end
|
||||
@@ -420,7 +449,7 @@ end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
local src = source
|
||||
data = data or {}
|
||||
data = type(data) == "table" and data or {}
|
||||
local correlation_id = data.correlationId
|
||||
local media_type = data.mediaType
|
||||
if type(correlation_id) ~= "string" or #correlation_id > 80
|
||||
@@ -454,6 +483,9 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
capture_token = capture_token,
|
||||
correlation_id = correlation_id,
|
||||
media_type = media_type,
|
||||
mime_type = media_type == "video" and "video/webm"
|
||||
or ({ png = "image/png", webp = "image/webp" })[tostring(Config.Media.Photo.Encoding):lower()]
|
||||
or "image/jpeg",
|
||||
owner = owner,
|
||||
source = src,
|
||||
}
|
||||
@@ -474,7 +506,7 @@ end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
||||
local src = source
|
||||
data = data or {}
|
||||
data = type(data) == "table" and data or {}
|
||||
local request_id = data.requestId
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
@@ -496,14 +528,14 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
||||
local result
|
||||
if owner.account_id then
|
||||
result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
|
||||
VALUES (?, NULL, ?, ?, ?)
|
||||
]], { owner.account_id, verified.url, verified.remote_id, state.media_type })
|
||||
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`)
|
||||
VALUES (?, NULL, ?, ?, ?, ?)
|
||||
]], { owner.account_id, verified.url, verified.remote_id, state.media_type, verified.mime_type })
|
||||
else
|
||||
result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
|
||||
VALUES (NULL, ?, ?, ?, ?)
|
||||
]], { owner.imei, verified.url, verified.remote_id, state.media_type })
|
||||
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`)
|
||||
VALUES (NULL, ?, ?, ?, ?, ?)
|
||||
]], { owner.imei, verified.url, verified.remote_id, state.media_type, verified.mime_type })
|
||||
end
|
||||
pending_uploads[request_id] = nil
|
||||
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
||||
@@ -522,7 +554,7 @@ end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:cancel-upload", function(data)
|
||||
local src = source
|
||||
local request_id = data and data.requestId
|
||||
local request_id = type(data) == "table" and data.requestId or nil
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if state and state.source == src and not state.completing then
|
||||
pending_uploads[request_id] = nil
|
||||
@@ -532,7 +564,7 @@ end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
||||
local src = source
|
||||
local request_id = data and data.requestId
|
||||
local request_id = type(data) == "table" and data.requestId or nil
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
return
|
||||
@@ -550,7 +582,7 @@ end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:delete", function(data)
|
||||
local src = source
|
||||
data = data or {}
|
||||
data = type(data) == "table" and data or {}
|
||||
local correlation_id = data.correlationId
|
||||
local media_id = tonumber(data.id)
|
||||
if type(correlation_id) ~= "string" or #correlation_id > 80 or not media_id then
|
||||
@@ -646,6 +678,7 @@ AddEventHandler("playerDropped", function()
|
||||
end)
|
||||
|
||||
if not api_configured() then
|
||||
print("^3[sky_phone] Camera and Gallery uploads are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
|
||||
print(("^3[sky_phone] Camera and Gallery uploads are disabled until the %s server convar is set.^7")
|
||||
:format(tostring(media_config().ApiKeyConvar)))
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -32,7 +32,7 @@ local attachment_assets = {
|
||||
local attachment_mimes = {
|
||||
gif = "image/gif",
|
||||
image = "image/jpeg",
|
||||
video = "video/mp4",
|
||||
video = "video/webm",
|
||||
}
|
||||
|
||||
local function allowed_media_url(value)
|
||||
@@ -208,7 +208,9 @@ local function validate_attachment(source, device, message_type, data)
|
||||
return nil
|
||||
end
|
||||
local payload = data.mediaAssetId
|
||||
if not valid_attachment_asset(message_type, payload) then
|
||||
local built_in_asset = valid_attachment_asset(message_type, payload)
|
||||
local mime = built_in_asset and attachment_mimes[message_type] or nil
|
||||
if not built_in_asset then
|
||||
if message_type == "gif" then
|
||||
return nil
|
||||
end
|
||||
@@ -226,7 +228,7 @@ local function validate_attachment(source, device, message_type, data)
|
||||
params = { media_id, device.imei }
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `url`, `media_type` FROM `sky_phone_media`
|
||||
SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
|
||||
WHERE `id` = ? AND %s
|
||||
LIMIT 1
|
||||
]]):format(condition), params)
|
||||
@@ -241,6 +243,7 @@ local function validate_attachment(source, device, message_type, data)
|
||||
return nil
|
||||
end
|
||||
payload = media.url
|
||||
mime = type(media.mime_type) == "string" and media.mime_type ~= "" and media.mime_type or nil
|
||||
end
|
||||
local duration = nil
|
||||
if message_type == "video" and data.mediaDurationMs ~= nil then
|
||||
@@ -252,7 +255,7 @@ local function validate_attachment(source, device, message_type, data)
|
||||
end
|
||||
return {
|
||||
duration = duration,
|
||||
mime = attachment_mimes[message_type],
|
||||
mime = mime,
|
||||
payload = payload,
|
||||
}
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user