ADD - voice memo app

This commit is contained in:
Leon.Schmidt
2026-08-13 16:16:32 +02:00
parent 223cb4a600
commit f4ed86fd63
29 changed files with 3234 additions and 50 deletions
+7 -1
View File
@@ -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>
+16
View File
@@ -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(
+52
View File
@@ -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 }
})
+67 -2
View File
@@ -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.',
+1
View File
@@ -16,6 +16,7 @@ export type BuiltinPhoneAppId =
| 'mail'
| 'map'
| 'notes'
| 'memos'
| 'radio'
| 'music'
| 'photos'
+2
View File
@@ -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
+71
View File
@@ -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'
)
}
+2 -2
View File
@@ -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,
})
+2 -1
View File
@@ -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
+225 -2
View File
@@ -1,3 +1,5 @@
const { randomUUID } = require('node:crypto')
const cors = require('cors')
const express = require('express')
@@ -5,7 +7,7 @@ const app = express()
const port = Number(process.argv[2]) || 3001
app.use(cors())
app.use(express.json())
app.use(express.json({ limit: '3mb' }))
const lifecycleEndpoints = new Set([
'camera:setActive',
@@ -38,6 +40,14 @@ function unixTime(offsetSeconds = 0) {
return Math.floor(Date.now() / 1000) + offsetSeconds
}
function memoWaveform(phase = 0) {
return Array.from({ length: 48 }, (_, index) =>
Number(
(0.12 + Math.abs(Math.sin((index + phase) * 0.67)) * 0.76).toFixed(3),
),
)
}
let authenticated = true
let draft = null
const radioData = {
@@ -1956,6 +1966,53 @@ let mockNotes = [
updatedAt: Date.now() - 172_800_000,
},
]
let mockMemos = [
{
createdAt: Date.now() - 25 * 60_000,
durationMs: 42_100,
id: '4b918e0e-840e-4f35-99d7-c93b047bc3f7',
mediaId: 9101,
mimeType: 'audio/ogg',
note: 'Patrol route via Mission Row, Pillbox Hill and the Vespucci canals.',
pinned: true,
revision: 2,
sizeBytes: 132_840,
title: 'Night shift briefing',
updatedAt: Date.now() - 18 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(1),
},
{
createdAt: Date.now() - 3 * 60 * 60_000,
durationMs: 18_600,
id: '7df6287a-a3f1-49cc-a722-a950fc9a5dd2',
mediaId: 9102,
mimeType: 'audio/ogg',
note: 'Check the paint, engine sound and service history before making an offer.',
pinned: false,
revision: 1,
sizeBytes: 61_920,
title: 'Sultan RS inspection',
updatedAt: Date.now() - 3 * 60 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(5),
},
{
createdAt: Date.now() - 26 * 60 * 60_000,
durationMs: 7_400,
id: '0b2a0d0d-3eb7-4623-873e-829a96a9d525',
mediaId: 9103,
mimeType: 'audio/ogg',
note: 'Repair kit, flashlight and two bottles of water.',
pinned: false,
revision: 1,
sizeBytes: 26_480,
title: 'Supply reminder',
updatedAt: Date.now() - 26 * 60 * 60_000,
url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga',
waveform: memoWaveform(9),
},
]
let calendarEvents = [
{
endsAt: calendarTime(0, 10),
@@ -3479,8 +3536,16 @@ function companyWorkContext(testScenario = '') {
}
app.post('/api/:endpoint', async (request, response, next) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
console.log(
`[NUI] ${endpoint}`,
endpoint === 'memos:devCapture'
? {
...request.body,
audioDataUrl: `<${String(request.body.audioDataUrl ?? '').length} characters>`,
}
: request.body,
)
if (endpoint === 'music:bootstrap') {
response.json({ success: true, data: musicBootstrap() })
return
@@ -7715,6 +7780,7 @@ app.post('/api/:endpoint', (request, response) => {
name: 'Personal iFruit Phone',
sim: mockSim,
},
memos: mockMemos,
notes: mockNotes,
security: mockSecurity,
token: 'development',
@@ -8170,6 +8236,7 @@ app.post('/api/:endpoint', (request, response) => {
if (endpoint === 'device:factory-reset') {
authenticated = false
linkedAccount = null
mockMemos = []
mockNotes = []
mockMedia = []
calendarEvents = []
@@ -8941,6 +9008,162 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true })
return
}
if (endpoint === 'memos:list') {
response.json({ success: true, data: mockMemos })
return
}
if (endpoint === 'memos:devCapture') {
const correlationId =
typeof request.body.correlationId === 'string'
? request.body.correlationId
: ''
const title =
typeof request.body.title === 'string' ? request.body.title.trim() : ''
const note =
request.body.note === undefined
? ''
: typeof request.body.note === 'string'
? request.body.note.trim()
: null
const durationMs = Math.floor(Number(request.body.durationMs))
const mimeType =
request.body.mimeType === 'audio/ogg'
? 'audio/ogg'
: ['audio/webm', 'audio/webm;codecs=opus'].includes(
request.body.mimeType,
)
? 'audio/webm'
: null
const waveform = Array.isArray(request.body.waveform)
? request.body.waveform.map(Number)
: []
const audioDataUrl =
typeof request.body.audioDataUrl === 'string'
? request.body.audioDataUrl
: ''
const separator = audioDataUrl.indexOf(';base64,')
const audioMime =
separator > 5
? audioDataUrl.slice(5, separator).split(';', 1)[0].toLowerCase()
: ''
const encodedAudio = separator > 0 ? audioDataUrl.slice(separator + 8) : ''
const validAudio =
Boolean(mimeType) &&
audioMime === mimeType &&
encodedAudio.length > 0 &&
encodedAudio.length % 4 === 0 &&
/^[A-Za-z0-9+/]+={0,2}$/.test(encodedAudio)
const sizeBytes = validAudio
? Buffer.from(encodedAudio, 'base64').byteLength
: 0
if (
!correlationId ||
correlationId.length > 80 ||
!title ||
title.length > 120 ||
note === null ||
note.length > 2000 ||
!Number.isFinite(durationMs) ||
durationMs < 300 ||
durationMs > 300_000 ||
typeof request.body.pinned !== 'boolean' ||
waveform.length < 8 ||
waveform.length > 96 ||
waveform.some(
(sample) => !Number.isFinite(sample) || sample < 0 || sample > 1,
) ||
!validAudio ||
sizeBytes < 1 ||
sizeBytes > 2 * 1024 * 1024 ||
mockMemos.length >= 100
) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const now = Date.now()
const memo = {
createdAt: now,
durationMs,
id: randomUUID(),
mediaId:
Math.max(0, ...mockMemos.map((item) => Number(item.mediaId) || 0)) + 1,
mimeType,
note,
pinned: request.body.pinned,
revision: 1,
sizeBytes,
title,
updatedAt: now,
url: audioDataUrl,
waveform,
}
mockMemos.unshift(memo)
response.json({ success: true, data: memo })
return
}
if (endpoint === 'memos:update') {
const id = typeof request.body.id === 'string' ? request.body.id : ''
const title =
typeof request.body.title === 'string' ? request.body.title.trim() : ''
const note =
request.body.note === undefined
? ''
: typeof request.body.note === 'string'
? request.body.note.trim()
: null
const revision = Math.floor(Number(request.body.revision) || 0)
if (
id.length !== 36 ||
!title ||
title.length > 120 ||
note === null ||
note.length > 2000 ||
typeof request.body.pinned !== 'boolean' ||
revision < 1
) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const index = mockMemos.findIndex(
(memo) => memo.id === id && memo.revision === revision,
)
if (index < 0) {
response.json({ success: false, error: 'conflict', data: mockMemos })
return
}
const memo = {
...mockMemos[index],
note,
pinned: request.body.pinned,
revision: revision + 1,
title,
updatedAt: Date.now(),
}
mockMemos[index] = memo
mockMemos.sort(
(left, right) =>
Number(right.pinned) - Number(left.pinned) ||
right.updatedAt - left.updatedAt ||
right.id.localeCompare(left.id),
)
response.json({ success: true, data: memo })
return
}
if (endpoint === 'memos:delete') {
const id = typeof request.body.id === 'string' ? request.body.id : ''
if (id.length !== 36) {
response.json({ success: false, error: 'invalid_memo' })
return
}
const index = mockMemos.findIndex((memo) => memo.id === id)
if (index < 0) {
response.json({ success: false, error: 'memo_not_found' })
return
}
mockMemos.splice(index, 1)
response.json({ success: true, data: { id } })
return
}
if (endpoint === 'notes:list') {
response.json({ success: true, data: mockNotes })
return
+84
View File
@@ -46,6 +46,7 @@ const browserDataRequests = [
['marketplace:profile', {}],
['messages:conversations', {}],
['messages:gifs', { query: 'party' }],
['memos:list', {}],
['music:bootstrap', {}],
['notes:list', {}],
['pages:list', {}],
@@ -82,6 +83,89 @@ async function expectSuccess(baseUrl, endpoint, body = {}, data = false) {
}
async function verifyStatefulActions(baseUrl) {
const memoBootstrap = await expectSuccess(
baseUrl,
'development:bootstrap',
{},
true,
)
assert(
Array.isArray(memoBootstrap.memos) && memoBootstrap.memos.length >= 3,
'development:bootstrap did not include realistic memo data',
)
assert(
memoBootstrap.memos.every(
(memo) =>
typeof memo.url === 'string' &&
memo.url.length > 0 &&
Array.isArray(memo.waveform) &&
memo.waveform.length >= 8,
),
'development:bootstrap returned an invalid memo response shape',
)
let memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
const capturedMemo = await expectSuccess(
baseUrl,
'memos:devCapture',
{
audioDataUrl: 'data:audio/webm;base64,T2dnUw==',
correlationId: 'browser-smoke-memo',
durationMs: 1_250,
mimeType: 'audio/webm;codecs=opus',
note: 'Recorded in the browser preview.',
pinned: false,
title: 'Browser recording',
waveform: Array(16).fill(0.35),
},
true,
)
assert.equal(capturedMemo.title, 'Browser recording')
assert.equal(capturedMemo.mimeType, 'audio/webm')
assert.equal(capturedMemo.sizeBytes, 4)
assert.match(capturedMemo.id, /^[0-9a-f-]{36}$/)
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
assert(
memos.some((item) => item.id === capturedMemo.id),
'memos:devCapture did not persist',
)
await expectSuccess(baseUrl, 'memos:delete', { id: capturedMemo.id }, true)
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
const memo = memos.find((item) => !item.pinned)
assert(memo, 'memos:list did not return an editable memo')
const updatedMemo = await expectSuccess(
baseUrl,
'memos:update',
{
id: memo.id,
note: 'Updated by the browser mock smoke test.',
pinned: true,
revision: memo.revision,
title: 'Updated browser memo',
},
true,
)
assert.equal(updatedMemo.id, memo.id)
assert.equal(updatedMemo.title, 'Updated browser memo')
assert.equal(updatedMemo.note, 'Updated by the browser mock smoke test.')
assert.equal(updatedMemo.pinned, true)
assert.equal(updatedMemo.revision, memo.revision + 1)
assert(Array.isArray(updatedMemo.waveform) && updatedMemo.waveform.length > 0)
const deletedMemo = await expectSuccess(
baseUrl,
'memos:delete',
{ id: updatedMemo.id },
true,
)
assert.deepEqual(deletedMemo, { id: updatedMemo.id })
memos = await expectSuccess(baseUrl, 'memos:list', {}, true)
assert(
!memos.some((item) => item.id === updatedMemo.id),
'memos:delete did not persist',
)
const noteId = `browser-note-${Date.now()}`
let notes = await expectSuccess(
baseUrl,
+3 -4
View File
@@ -3,9 +3,8 @@ import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig(({ command }) => ({
export default defineConfig({
base: './',
build: {
assetsDir: 'assets',
@@ -23,7 +22,7 @@ export default defineConfig(({ command }) => ({
},
},
},
plugins: [command === 'serve' && vueDevTools(), tailwindcss(), vue()],
plugins: [tailwindcss(), vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -38,4 +37,4 @@ export default defineConfig(({ command }) => ({
// Open the UI through localhost so embeds still receive that page origin.
host: '127.0.0.1',
},
}))
})