mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - voice memo app
This commit is contained in:
@@ -13,6 +13,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue'
|
||||
import PhoneControlCenter from '@/components/PhoneControlCenter.vue'
|
||||
import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue'
|
||||
import PhoneMemoRecorder from '@/components/PhoneMemoRecorder.vue'
|
||||
import PhoneLockScreen from '@/components/PhoneLockScreen.vue'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import PhoneNotifications from '@/components/PhoneNotifications.vue'
|
||||
@@ -47,6 +48,7 @@ import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { useWidgetsStore } from '@/stores/widgets'
|
||||
import { isPhoneAppId } from '@/config/apps'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { useMemosStore } from '@/stores/memos'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import {
|
||||
@@ -265,6 +267,7 @@ const appCatalog = useAppCatalogStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const widgets = useWidgetsStore()
|
||||
const notes = useNotesStore()
|
||||
const memos = useMemosStore()
|
||||
const weather = useWeatherStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const notifications = useNotificationsStore()
|
||||
@@ -377,6 +380,7 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
payload.account?.email ?? '',
|
||||
)
|
||||
notes.hydrate(payload.notes ?? [])
|
||||
memos.hydrate(payload.memos ?? [])
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
media.hydrate(payload.device?.data.media?.payload)
|
||||
@@ -454,6 +458,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
|
||||
type: 'registered',
|
||||
},
|
||||
},
|
||||
memos: [],
|
||||
notes: [],
|
||||
token: 'development',
|
||||
})
|
||||
@@ -1291,6 +1296,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<PhoneMediaCapture />
|
||||
<PhoneMemoRecorder />
|
||||
<RadioHud />
|
||||
<PayphoneOverlay />
|
||||
<SimPhonePicker
|
||||
@@ -1419,7 +1425,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Transition>
|
||||
<PhoneNotifications
|
||||
:notification="phone.isOpen ? null : notifications.current"
|
||||
:notification="notifications.current"
|
||||
@close="notifications.dismissCurrent()"
|
||||
@open="openNotificationPreview"
|
||||
/>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,604 @@
|
||||
<script setup lang="ts">
|
||||
import fixWebmDuration from 'fix-webm-duration'
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
|
||||
import { useMemosStore } from '@/stores/memos'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
MemoDto,
|
||||
MemoRecorderState,
|
||||
MemoRecorderStateName,
|
||||
MemoRecordingMetadata,
|
||||
MemoUploadReady,
|
||||
MemoUploadResult,
|
||||
} from '@/types/memos'
|
||||
import { isMemoDto } from '@/types/memos'
|
||||
import {
|
||||
bindMediaRecorderError,
|
||||
stopMediaRecorder,
|
||||
} from '@/utils/mediaRecorder'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
|
||||
type PendingMemo = {
|
||||
abortController?: AbortController
|
||||
blob: Blob
|
||||
fileName: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000
|
||||
const MAX_RECORDING_BYTES = 2 * 1024 * 1024
|
||||
const WAVEFORM_SAMPLES = 96
|
||||
const LIVE_LEVEL_SAMPLES = 32
|
||||
const AUDIO_BITS_PER_SECOND = 24_000
|
||||
const memos = useMemosStore()
|
||||
const phone = usePhoneStore()
|
||||
const pendingMemos = new Map<string, PendingMemo>()
|
||||
|
||||
let recorder: MediaRecorder | null = null
|
||||
let mediaStream: MediaStream | null = null
|
||||
let audioContext: AudioContext | null = null
|
||||
let analyser: AnalyserNode | null = null
|
||||
let recordingTimer: number | undefined
|
||||
let recordingGeneration = 0
|
||||
let recordingStartedAt = 0
|
||||
let pausedStartedAt = 0
|
||||
let totalPausedMs = 0
|
||||
let recordingBytes = 0
|
||||
let recordingTooLarge = false
|
||||
let recordingChunks: Blob[] = []
|
||||
let recordingSamples: number[] = []
|
||||
let liveLevels: number[] = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
||||
let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
|
||||
let currentState: MemoRecorderStateName = 'idle'
|
||||
let currentElapsedMs = 0
|
||||
let currentCorrelationId = ''
|
||||
let removeRecorderErrorListener: (() => void) | null = null
|
||||
|
||||
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
|
||||
currentState = state
|
||||
const data: MemoRecorderState = {
|
||||
elapsedMs: Math.round(currentElapsedMs),
|
||||
levels: [...liveLevels],
|
||||
state,
|
||||
...(error ? { error } : {}),
|
||||
}
|
||||
window.postMessage({ data, type: 'memo:recordState' }, '*')
|
||||
}
|
||||
|
||||
function updateMetadata(data: Record<string, unknown>): void {
|
||||
if (typeof data.title === 'string') metadata.title = data.title
|
||||
if (typeof data.note === 'string') metadata.note = data.note
|
||||
if (typeof data.pinned === 'boolean') metadata.pinned = data.pinned
|
||||
}
|
||||
|
||||
function elapsedAt(now = performance.now()): number {
|
||||
if (!recordingStartedAt) return currentElapsedMs
|
||||
const activePause = pausedStartedAt ? now - pausedStartedAt : 0
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
MAX_DURATION_MS,
|
||||
now - recordingStartedAt - totalPausedMs - activePause,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function recordingMime(): string | null {
|
||||
if (typeof MediaRecorder === 'undefined') return null
|
||||
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
||||
return 'audio/webm;codecs=opus'
|
||||
}
|
||||
if (MediaRecorder.isTypeSupported('audio/webm')) return 'audio/webm'
|
||||
return null
|
||||
}
|
||||
|
||||
function sampleMicrophone(): void {
|
||||
if (!analyser || currentState !== 'recording') return
|
||||
const values = new Uint8Array(analyser.fftSize)
|
||||
analyser.getByteTimeDomainData(values)
|
||||
let total = 0
|
||||
for (const value of values) total += Math.abs(value - 128) / 128
|
||||
const level = Math.max(0.08, Math.min(1, (total / values.length) * 4.5))
|
||||
recordingSamples.push(level)
|
||||
liveLevels = [...liveLevels.slice(1), level]
|
||||
currentElapsedMs = elapsedAt()
|
||||
postRecorderState('recording')
|
||||
if (currentElapsedMs >= MAX_DURATION_MS) void stopRecording({})
|
||||
}
|
||||
|
||||
function compressedWaveform(): number[] {
|
||||
if (!recordingSamples.length) return Array(WAVEFORM_SAMPLES).fill(0.08)
|
||||
const result: number[] = []
|
||||
const bucketSize = recordingSamples.length / WAVEFORM_SAMPLES
|
||||
for (let index = 0; index < WAVEFORM_SAMPLES; index += 1) {
|
||||
const start = Math.floor(index * bucketSize)
|
||||
const end = Math.max(start + 1, Math.floor((index + 1) * bucketSize))
|
||||
const bucket = recordingSamples.slice(start, end)
|
||||
const average = bucket.length
|
||||
? bucket.reduce((sum, value) => sum + value, 0) / bucket.length
|
||||
: (recordingSamples.at(-1) ?? 0.08)
|
||||
result.push(Math.max(0.08, Math.min(1, average)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function blobDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result)
|
||||
return
|
||||
}
|
||||
reject(new Error('invalid_audio_data'))
|
||||
})
|
||||
reader.addEventListener('error', () => {
|
||||
reject(reader.error ?? new Error('audio_read_failed'))
|
||||
})
|
||||
reader.addEventListener('abort', () =>
|
||||
reject(new Error('audio_read_failed')),
|
||||
)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
function resetRecordingData(): void {
|
||||
recordingChunks = []
|
||||
recordingSamples = []
|
||||
recordingBytes = 0
|
||||
recordingTooLarge = false
|
||||
recordingStartedAt = 0
|
||||
pausedStartedAt = 0
|
||||
totalPausedMs = 0
|
||||
currentElapsedMs = 0
|
||||
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
|
||||
}
|
||||
|
||||
function cleanupRecorder(stopActive: boolean): void {
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
recordingTimer = undefined
|
||||
removeRecorderErrorListener?.()
|
||||
removeRecorderErrorListener = null
|
||||
const activeRecorder = recorder
|
||||
recorder = null
|
||||
if (activeRecorder) {
|
||||
activeRecorder.ondataavailable = null
|
||||
if (stopActive && activeRecorder.state !== 'inactive') {
|
||||
try {
|
||||
activeRecorder.stop()
|
||||
} catch (error) {
|
||||
console.error('[Memos] Could not stop the media recorder.', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
mediaStream?.getTracks().forEach((track) => track.stop())
|
||||
mediaStream = null
|
||||
void audioContext?.close()
|
||||
audioContext = null
|
||||
analyser = null
|
||||
}
|
||||
|
||||
function failRecording(error: string, generation = recordingGeneration): void {
|
||||
if (generation !== recordingGeneration) return
|
||||
recordingGeneration += 1
|
||||
cleanupRecorder(true)
|
||||
resetRecordingData()
|
||||
postRecorderState('error', error)
|
||||
}
|
||||
|
||||
async function startRecording(data: Record<string, unknown>): Promise<void> {
|
||||
if (!phone.isOpen) {
|
||||
console.error('[Memos] Cannot start a recording while the phone is closed.')
|
||||
return
|
||||
}
|
||||
if (!['idle', 'error'].includes(currentState)) {
|
||||
console.error(
|
||||
`[Memos] Cannot start while recorder state is ${currentState}.`,
|
||||
)
|
||||
postRecorderState(currentState, 'operation_in_progress')
|
||||
return
|
||||
}
|
||||
if (
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
typeof MediaRecorder === 'undefined'
|
||||
) {
|
||||
postRecorderState('error', 'microphone_unavailable')
|
||||
return
|
||||
}
|
||||
const mimeType = recordingMime()
|
||||
if (!mimeType) {
|
||||
postRecorderState('error', 'microphone_unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const generation = ++recordingGeneration
|
||||
metadata = { note: '', pinned: false, title: '' }
|
||||
updateMetadata(data)
|
||||
resetRecordingData()
|
||||
postRecorderState('starting')
|
||||
try {
|
||||
const acquiredStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
if (generation !== recordingGeneration) {
|
||||
acquiredStream.getTracks().forEach((track) => track.stop())
|
||||
return
|
||||
}
|
||||
mediaStream = acquiredStream
|
||||
recorder = new MediaRecorder(mediaStream, {
|
||||
audioBitsPerSecond: AUDIO_BITS_PER_SECOND,
|
||||
mimeType,
|
||||
})
|
||||
const activeRecorder = recorder
|
||||
activeRecorder.ondataavailable = (event) => {
|
||||
if (generation !== recordingGeneration || !event.data.size) return
|
||||
recordingChunks.push(event.data)
|
||||
recordingBytes += event.data.size
|
||||
if (recordingBytes > MAX_RECORDING_BYTES && !recordingTooLarge) {
|
||||
recordingTooLarge = true
|
||||
void stopRecording({})
|
||||
}
|
||||
}
|
||||
removeRecorderErrorListener = bindMediaRecorderError(
|
||||
activeRecorder,
|
||||
() => generation === recordingGeneration && recorder === activeRecorder,
|
||||
(event) => {
|
||||
console.error('[Memos] Media recorder failed while recording.', event)
|
||||
failRecording('recording_failed', generation)
|
||||
},
|
||||
)
|
||||
audioContext = new AudioContext()
|
||||
analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = 128
|
||||
audioContext.createMediaStreamSource(mediaStream).connect(analyser)
|
||||
activeRecorder.start(250)
|
||||
recordingStartedAt = performance.now()
|
||||
recordingTimer = window.setInterval(sampleMicrophone, 100)
|
||||
postRecorderState('recording')
|
||||
} catch (error) {
|
||||
if (generation !== recordingGeneration) return
|
||||
console.error('[Memos] Could not start audio recording.', error)
|
||||
failRecording('microphone_unavailable', generation)
|
||||
}
|
||||
}
|
||||
|
||||
function pauseRecording(): void {
|
||||
if (
|
||||
!recorder ||
|
||||
recorder.state !== 'recording' ||
|
||||
currentState !== 'recording'
|
||||
) {
|
||||
console.error('[Memos] Cannot pause because no memo is being recorded.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
currentElapsedMs = elapsedAt()
|
||||
pausedStartedAt = performance.now()
|
||||
recorder.pause()
|
||||
postRecorderState('paused')
|
||||
} catch (error) {
|
||||
console.error('[Memos] Could not pause the media recorder.', error)
|
||||
failRecording('recording_failed')
|
||||
}
|
||||
}
|
||||
|
||||
function resumeRecording(): void {
|
||||
if (!recorder || recorder.state !== 'paused' || currentState !== 'paused') {
|
||||
console.error('[Memos] Cannot resume because no memo recording is paused.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const now = performance.now()
|
||||
totalPausedMs += pausedStartedAt ? now - pausedStartedAt : 0
|
||||
pausedStartedAt = 0
|
||||
recorder.resume()
|
||||
currentElapsedMs = elapsedAt(now)
|
||||
postRecorderState('recording')
|
||||
} catch (error) {
|
||||
console.error('[Memos] Could not resume the media recorder.', error)
|
||||
failRecording('recording_failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording(data: Record<string, unknown>): Promise<void> {
|
||||
const activeRecorder = recorder
|
||||
if (
|
||||
!activeRecorder ||
|
||||
activeRecorder.state === 'inactive' ||
|
||||
!['recording', 'paused'].includes(currentState)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const generation = recordingGeneration
|
||||
updateMetadata(data)
|
||||
currentElapsedMs = elapsedAt()
|
||||
postRecorderState('stopping')
|
||||
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
|
||||
recordingTimer = undefined
|
||||
removeRecorderErrorListener?.()
|
||||
removeRecorderErrorListener = null
|
||||
|
||||
try {
|
||||
await stopMediaRecorder(activeRecorder)
|
||||
if (generation !== recordingGeneration) return
|
||||
const mimeType = activeRecorder.mimeType || 'audio/webm'
|
||||
const durationMs = Math.max(300, Math.round(currentElapsedMs))
|
||||
let blob = new Blob(recordingChunks, { type: mimeType })
|
||||
blob = await (
|
||||
fixWebmDuration as unknown as (
|
||||
source: Blob,
|
||||
duration: number,
|
||||
options: { logger: boolean },
|
||||
) => Promise<Blob>
|
||||
)(blob, durationMs, { logger: false })
|
||||
if (generation !== recordingGeneration) return
|
||||
const waveform = compressedWaveform()
|
||||
const finalMetadata = { ...metadata }
|
||||
const exceededSizeLimit = recordingTooLarge
|
||||
cleanupRecorder(false)
|
||||
resetRecordingData()
|
||||
|
||||
if (exceededSizeLimit || !blob.size || blob.size > MAX_RECORDING_BYTES) {
|
||||
postRecorderState('error', 'recording_too_large')
|
||||
return
|
||||
}
|
||||
|
||||
const correlationId = `memo-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
currentCorrelationId = correlationId
|
||||
currentElapsedMs = durationMs
|
||||
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
|
||||
const uploadData = {
|
||||
correlationId,
|
||||
durationMs,
|
||||
mimeType,
|
||||
note: finalMetadata.note,
|
||||
pinned: finalMetadata.pinned,
|
||||
title: finalMetadata.title,
|
||||
waveform,
|
||||
}
|
||||
postRecorderState('uploading')
|
||||
if (import.meta.env.DEV) {
|
||||
const response = await nuiCall<MemoDto>('memos:devCapture', {
|
||||
...uploadData,
|
||||
audioDataUrl: await blobDataUrl(blob),
|
||||
})
|
||||
if (
|
||||
generation !== recordingGeneration ||
|
||||
currentCorrelationId !== correlationId
|
||||
) {
|
||||
return
|
||||
}
|
||||
pendingMemos.delete(correlationId)
|
||||
currentCorrelationId = ''
|
||||
if (!response.success || !isMemoDto(response.data)) {
|
||||
postRecorderState('error', response.error ?? 'invalid_memo')
|
||||
return
|
||||
}
|
||||
memos.upsert(response.data)
|
||||
postRecorderState('idle')
|
||||
window.postMessage({ data: response.data, type: 'memo:saved' }, '*')
|
||||
return
|
||||
}
|
||||
pendingMemos.set(correlationId, {
|
||||
blob,
|
||||
fileName: `${correlationId}.webm`,
|
||||
})
|
||||
const response = await nuiCall('memos:requestUpload', uploadData)
|
||||
if (
|
||||
generation !== recordingGeneration ||
|
||||
currentCorrelationId !== correlationId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!response.success) {
|
||||
pendingMemos.delete(correlationId)
|
||||
currentCorrelationId = ''
|
||||
postRecorderState('error', response.error ?? 'request_failed')
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation !== recordingGeneration) return
|
||||
console.error('[Memos] Could not finalize audio recording.', error)
|
||||
if (currentCorrelationId) pendingMemos.delete(currentCorrelationId)
|
||||
currentCorrelationId = ''
|
||||
cleanupRecorder(true)
|
||||
resetRecordingData()
|
||||
postRecorderState('error', 'recording_failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelPendingMemo(
|
||||
correlationId: string,
|
||||
pending: PendingMemo,
|
||||
): Promise<void> {
|
||||
pending.abortController?.abort()
|
||||
pendingMemos.delete(correlationId)
|
||||
await nuiCall('memos:cancelUpload', {
|
||||
correlationId,
|
||||
...(pending.requestId ? { requestId: pending.requestId } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
function cancelRecording(): void {
|
||||
recordingGeneration += 1
|
||||
cleanupRecorder(true)
|
||||
resetRecordingData()
|
||||
for (const [correlationId, pending] of pendingMemos) {
|
||||
void cancelPendingMemo(correlationId, pending)
|
||||
}
|
||||
pendingMemos.clear()
|
||||
currentCorrelationId = ''
|
||||
postRecorderState('idle')
|
||||
}
|
||||
|
||||
async function failUpload(requestId: string, error: string): Promise<void> {
|
||||
await nuiCall('memos:failUpload', { error, requestId })
|
||||
}
|
||||
|
||||
async function uploadReady(ready: MemoUploadReady): Promise<void> {
|
||||
const pending = pendingMemos.get(ready.correlationId)
|
||||
if (!pending) {
|
||||
await nuiCall('memos:cancelUpload', {
|
||||
correlationId: ready.correlationId,
|
||||
requestId: ready.requestId,
|
||||
})
|
||||
return
|
||||
}
|
||||
pending.requestId = ready.requestId
|
||||
const form = new FormData()
|
||||
form.append('file', pending.blob, pending.fileName)
|
||||
form.append(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
captureToken: ready.captureToken,
|
||||
purpose: 'memo',
|
||||
source: 'sky_phone',
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
pending.abortController = controller
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
ready.uploadTimeoutMs ?? 25_000,
|
||||
)
|
||||
try {
|
||||
const response = await fetch(ready.presignedUrl, {
|
||||
body: form,
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const body = (await response.json()) as {
|
||||
data?: { id?: string; url?: string }
|
||||
id?: string
|
||||
url?: string
|
||||
}
|
||||
const uploaded = body.data ?? body
|
||||
if (!response.ok || !uploaded.id || !uploaded.url) {
|
||||
throw new Error('upload_failed')
|
||||
}
|
||||
const complete = await nuiCall('memos:completeUpload', {
|
||||
remoteId: uploaded.id,
|
||||
requestId: ready.requestId,
|
||||
url: uploaded.url,
|
||||
})
|
||||
if (!complete.success) throw new Error('upload_failed')
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted && !pendingMemos.has(ready.correlationId)) {
|
||||
return
|
||||
}
|
||||
const errorCode =
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? 'upload_timeout'
|
||||
: 'upload_failed'
|
||||
console.error('[Memos] Could not upload the audio recording.', error)
|
||||
pendingMemos.delete(ready.correlationId)
|
||||
await failUpload(ready.requestId, errorCode)
|
||||
currentCorrelationId = ''
|
||||
postRecorderState('error', errorCode)
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function resultMemo(data: Record<string, unknown>): MemoDto | null {
|
||||
const candidate = data.memo ?? data.data ?? data
|
||||
return isMemoDto(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
function uploadResult(data: Record<string, unknown>): void {
|
||||
const result = data as Partial<MemoUploadResult>
|
||||
const correlationId =
|
||||
typeof result.correlationId === 'string' ? result.correlationId : ''
|
||||
if (!correlationId) return
|
||||
const pending = pendingMemos.get(correlationId)
|
||||
pending?.abortController?.abort()
|
||||
pendingMemos.delete(correlationId)
|
||||
if (currentCorrelationId !== correlationId) return
|
||||
currentCorrelationId = ''
|
||||
|
||||
if (result.success) {
|
||||
const memo = resultMemo(data)
|
||||
if (!memo) {
|
||||
console.error('[Memos] Upload result did not contain a valid memo.')
|
||||
postRecorderState('error', 'invalid_memo')
|
||||
return
|
||||
}
|
||||
memos.upsert(memo)
|
||||
postRecorderState('idle')
|
||||
window.postMessage({ data: memo, type: 'memo:saved' }, '*')
|
||||
void memos.load()
|
||||
return
|
||||
}
|
||||
postRecorderState(
|
||||
'error',
|
||||
typeof result.error === 'string' ? result.error : 'upload_failed',
|
||||
)
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent): void {
|
||||
if (!isTrustedRootMessageSource(event.source, window)) return
|
||||
const message = event.data as {
|
||||
data?: Record<string, unknown>
|
||||
type?: string
|
||||
}
|
||||
const data = message.data ?? {}
|
||||
if (message.type === 'memo:recordStart') {
|
||||
void startRecording(data)
|
||||
} else if (message.type === 'memo:recordPause') {
|
||||
pauseRecording()
|
||||
} else if (message.type === 'memo:recordResume') {
|
||||
resumeRecording()
|
||||
} else if (message.type === 'memo:recordStop') {
|
||||
void stopRecording(data)
|
||||
} else if (message.type === 'memo:recordCancel') {
|
||||
cancelRecording()
|
||||
} else if (message.type === 'memos:uploadReady') {
|
||||
void uploadReady(data as MemoUploadReady)
|
||||
} else if (message.type === 'memos:uploadResult') {
|
||||
uploadResult(data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('message', onMessage))
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
phone.isOpen,
|
||||
phone.device?.imei ?? null,
|
||||
phone.deviceSessionToken,
|
||||
] as const,
|
||||
([isOpen, imei, sessionToken], previous) => {
|
||||
const deviceSessionChanged =
|
||||
previous !== undefined &&
|
||||
previous[0] &&
|
||||
(previous[1] !== imei || previous[2] !== sessionToken)
|
||||
if (!isOpen || deviceSessionChanged) cancelRecording()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
recordingGeneration += 1
|
||||
cleanupRecorder(true)
|
||||
for (const [correlationId, pending] of pendingMemos) {
|
||||
void cancelPendingMemo(correlationId, pending)
|
||||
}
|
||||
pendingMemos.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="phone-memo-recorder" aria-hidden="true"></span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.phone-memo-recorder {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AudioLines,
|
||||
Calculator,
|
||||
Bomb,
|
||||
Blocks,
|
||||
@@ -48,6 +49,7 @@ import messagesIcon from '@/assets/img/app-icons/sms.webp'
|
||||
import darkChatIcon from '@/assets/img/app-icons/darkchat.webp'
|
||||
import notesIcon from '@/assets/img/app-icons/notes.webp'
|
||||
import radioIcon from '@/assets/img/app-icons/radio.webp'
|
||||
import memosIcon from '@/assets/img/app-icons/memos.webp'
|
||||
import photosIcon from '@/assets/img/app-icons/gallery.webp'
|
||||
import phoneIcon from '@/assets/img/app-icons/phone.webp'
|
||||
import settingsIcon from '@/assets/img/app-icons/settings.svg'
|
||||
@@ -393,6 +395,20 @@ export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
|
||||
labelKey: 'Apps.notes.name',
|
||||
route: '/apps/notes',
|
||||
},
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
defineAsyncComponent(() => import('@/views/apps/MemosApp.vue')),
|
||||
),
|
||||
dockOrder: null,
|
||||
gridOrder: 8,
|
||||
icon: markRaw(AudioLines),
|
||||
iconClass: '',
|
||||
iconImage: memosIcon,
|
||||
id: 'memos',
|
||||
labelKey: 'Apps.memos.name',
|
||||
route: '/apps/memos',
|
||||
},
|
||||
{
|
||||
category: 'productivity',
|
||||
component: markRaw(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { MemoDto, MemoUpdate } from '@/types/memos'
|
||||
import { isMemoDto } from '@/types/memos'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const useMemosStore = defineStore('memos', () => {
|
||||
const memos = ref<MemoDto[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
function hydrate(items: MemoDto[]): void {
|
||||
memos.value = Array.isArray(items) ? items.filter(isMemoDto) : []
|
||||
}
|
||||
|
||||
function upsert(memo: MemoDto): void {
|
||||
const index = memos.value.findIndex((item) => item.id === memo.id)
|
||||
if (index >= 0) memos.value[index] = memo
|
||||
else memos.value.unshift(memo)
|
||||
memos.value.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
}
|
||||
|
||||
async function load(): Promise<NuiResponse<MemoDto[]>> {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await nuiCall<MemoDto[]>('memos:list')
|
||||
if (response.success && Array.isArray(response.data)) hydrate(response.data)
|
||||
return response
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function update(
|
||||
id: string,
|
||||
changes: MemoUpdate,
|
||||
): Promise<NuiResponse<MemoDto>> {
|
||||
const response = await nuiCall<MemoDto>('memos:update', { id, ...changes })
|
||||
if (response.success && isMemoDto(response.data)) upsert(response.data)
|
||||
return response
|
||||
}
|
||||
|
||||
async function deleteMemo(id: string): Promise<NuiResponse<{ id: string }>> {
|
||||
const response = await nuiCall<{ id: string }>('memos:delete', { id })
|
||||
if (response.success) {
|
||||
memos.value = memos.value.filter((memo) => memo.id !== id)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
return { deleteMemo, hydrate, load, loading, memos, update, upsert }
|
||||
})
|
||||
@@ -33,6 +33,7 @@ export type PhoneOpenPayload = {
|
||||
device?: PhoneDevice
|
||||
lang?: string
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
notes?: DeviceBootstrap['notes']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
@@ -3434,6 +3435,71 @@ const defaultLocales: LocaleTree = {
|
||||
unpin: 'Unpin note',
|
||||
deleteNote: 'Delete note',
|
||||
},
|
||||
memos: {
|
||||
name: 'Memos',
|
||||
memo: 'Voice Memo',
|
||||
back: 'Back',
|
||||
actions: 'Memo actions',
|
||||
newMemo: 'New Recording',
|
||||
newMemoWithDate: 'New Recording · {date}',
|
||||
searchPlaceholder: 'Search Recordings',
|
||||
emptyTitle: 'No Recordings',
|
||||
emptyBody: 'Tap the record button to capture your first voice memo.',
|
||||
noResults: 'No Results',
|
||||
noResultsBody: 'Try searching for a different title or note.',
|
||||
recording: 'Recording',
|
||||
paused: 'Paused',
|
||||
preparing: 'Preparing microphone…',
|
||||
saving: 'Saving Recording…',
|
||||
stop: 'Stop',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
cancel: 'Cancel',
|
||||
done: 'Done',
|
||||
play: 'Play recording',
|
||||
pausePlayback: 'Pause recording',
|
||||
skipBack: 'Back 15 seconds',
|
||||
skipForward: 'Forward 15 seconds',
|
||||
playbackSpeed: 'Playback Speed',
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'Recording title',
|
||||
note: 'Note',
|
||||
notePlaceholder: 'Add details…',
|
||||
delete: 'Delete memo',
|
||||
deleteTitle: 'Delete Voice Memo?',
|
||||
deleteBody: 'This recording will be permanently deleted.',
|
||||
discardTitle: 'Discard Recording?',
|
||||
discardBody: 'The current recording will not be saved.',
|
||||
discard: 'Discard',
|
||||
keepRecording: 'Keep Recording',
|
||||
deleted: 'Memo deleted.',
|
||||
microphoneUnavailable: 'The microphone is unavailable.',
|
||||
recordingTooLarge: 'The recording is too large to save.',
|
||||
maximumDuration: 'The maximum recording duration was reached.',
|
||||
errors: {
|
||||
conflict: 'This memo changed on another device. It has been reloaded.',
|
||||
invalid_memo: 'The voice memo contains invalid data.',
|
||||
invalid_request: 'The memo request is invalid.',
|
||||
invalid_upload: 'The uploaded recording could not be verified.',
|
||||
invalid_upload_token: 'The recording upload could not be verified.',
|
||||
memo_limit: 'The limit of 100 voice memos has been reached.',
|
||||
memo_not_found: 'This voice memo no longer exists.',
|
||||
media_provider_failed: 'The voice memo upload service is unavailable.',
|
||||
media_provider_rate_limited:
|
||||
'The voice memo upload service is busy. Try again shortly.',
|
||||
media_provider_unauthorized:
|
||||
'The configured FiveManage API key was rejected.',
|
||||
missing_config: 'Voice memo uploads are not configured.',
|
||||
operation_in_progress: 'Finish the current recording first.',
|
||||
owner_changed: 'The active phone changed while saving the memo.',
|
||||
rate_limited: 'Too many memo changes. Try again shortly.',
|
||||
recording_failed: 'The recording could not be captured.',
|
||||
request_timeout: 'The memo request timed out.',
|
||||
upload_failed: 'The recording could not be uploaded.',
|
||||
upload_timeout: 'The recording upload timed out.',
|
||||
request_failed: 'Memos are temporarily unavailable.',
|
||||
},
|
||||
},
|
||||
photos: {
|
||||
name: 'Gallery',
|
||||
count: '{count} items',
|
||||
@@ -3457,8 +3523,7 @@ const defaultLocales: LocaleTree = {
|
||||
title: 'Import',
|
||||
chooseSource: 'Choose a website',
|
||||
linkTitle: 'Import from Link',
|
||||
linkBody:
|
||||
'Paste a direct link to an image or video from this website.',
|
||||
linkBody: 'Paste a direct link to an image or video from this website.',
|
||||
linkLabel: 'Image or video link',
|
||||
linkPlaceholder: 'https://...',
|
||||
linkCompleted: 'Media imported.',
|
||||
|
||||
@@ -16,6 +16,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'mail'
|
||||
| 'map'
|
||||
| 'notes'
|
||||
| 'memos'
|
||||
| 'radio'
|
||||
| 'music'
|
||||
| 'photos'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Note } from '@/utils/notes'
|
||||
import type { MemoDto } from '@/types/memos'
|
||||
import type { PhoneSim } from '@/types/phone'
|
||||
|
||||
export type DeviceDataEntry<T = unknown> = {
|
||||
@@ -42,6 +43,7 @@ export type IfruitAccount = {
|
||||
export type DeviceBootstrap = {
|
||||
account: IfruitAccount | null
|
||||
device: PhoneDevice
|
||||
memos: MemoDto[]
|
||||
notes: Note[]
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
export type MemoDto = {
|
||||
id: string
|
||||
title: string
|
||||
note: string
|
||||
url: string
|
||||
mimeType: string
|
||||
durationMs: number
|
||||
waveform: number[]
|
||||
pinned: boolean
|
||||
revision: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type MemoUpdate = Pick<MemoDto, 'title' | 'note' | 'pinned' | 'revision'>
|
||||
|
||||
export type MemoRecorderStateName =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopping'
|
||||
| 'uploading'
|
||||
| 'error'
|
||||
|
||||
export type MemoRecorderState = {
|
||||
state: MemoRecorderStateName
|
||||
elapsedMs: number
|
||||
levels: number[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type MemoRecordingMetadata = {
|
||||
title: string
|
||||
note: string
|
||||
pinned: boolean
|
||||
}
|
||||
|
||||
export type MemoUploadReady = {
|
||||
requestId: string
|
||||
correlationId: string
|
||||
captureToken: string
|
||||
presignedUrl: string
|
||||
uploadTimeoutMs?: number
|
||||
}
|
||||
|
||||
export type MemoUploadResult = {
|
||||
correlationId: string
|
||||
success: boolean
|
||||
error?: string
|
||||
memo?: MemoDto
|
||||
}
|
||||
|
||||
export function isMemoDto(value: unknown): value is MemoDto {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const memo = value as Partial<MemoDto>
|
||||
return (
|
||||
typeof memo.id === 'string' &&
|
||||
typeof memo.title === 'string' &&
|
||||
typeof memo.note === 'string' &&
|
||||
typeof memo.url === 'string' &&
|
||||
typeof memo.mimeType === 'string' &&
|
||||
typeof memo.durationMs === 'number' &&
|
||||
Array.isArray(memo.waveform) &&
|
||||
memo.waveform.every((sample) => typeof sample === 'number') &&
|
||||
typeof memo.pinned === 'boolean' &&
|
||||
typeof memo.revision === 'number' &&
|
||||
typeof memo.createdAt === 'number' &&
|
||||
typeof memo.updatedAt === 'number'
|
||||
)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ describe('preferences', () => {
|
||||
|
||||
expect(value.settings.appearanceMode).toBe('automatic')
|
||||
expect(value.settings.frame).toBe('black')
|
||||
expect(value.settings.graphicsMode).toBe('performance')
|
||||
expect(value.settings.graphicsMode).toBe('ultimate')
|
||||
expect(value.settings.notificationVolume).toBe(0)
|
||||
expect(value.settings.notificationDurationSeconds).toBe(30)
|
||||
expect(value.settings.phoneScale).toBe(150)
|
||||
@@ -127,7 +127,7 @@ describe('preferences', () => {
|
||||
bluetoothEnabled: true,
|
||||
cellularEnabled: true,
|
||||
focusMode: false,
|
||||
graphicsMode: 'performance',
|
||||
graphicsMode: 'ultimate',
|
||||
screenBrightness: 100,
|
||||
wifiEnabled: true,
|
||||
})
|
||||
|
||||
@@ -105,6 +105,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
|
||||
mail: { enabled: true, sounds: true },
|
||||
map: { enabled: true, sounds: true },
|
||||
notes: { enabled: true, sounds: true },
|
||||
memos: { enabled: true, sounds: true },
|
||||
radio: { enabled: true, sounds: true },
|
||||
music: { enabled: true, sounds: true },
|
||||
photos: { enabled: true, sounds: true },
|
||||
@@ -119,7 +120,7 @@ export const DEFAULT_PHONE_PREFERENCES: PhonePreferencesV1 = {
|
||||
cellularEnabled: true,
|
||||
focusMode: false,
|
||||
frame: 'black',
|
||||
graphicsMode: 'performance',
|
||||
graphicsMode: 'ultimate',
|
||||
notificationSound: 'chime',
|
||||
notificationDurationSeconds: 10,
|
||||
notificationVolume: 70,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user