notes(record): 加浏览器内直接录音(绕 iOS 录音机 App 文件不可见)
deploy notes / build-and-deploy (push) Successful in 2m11s

- 「🎙️ 直接录」按钮:navigator.mediaDevices.getUserMedia({audio:true}) → MediaRecorder
- 录音中按钮变红 + 计时器 + 脉冲;点 ⏹ 停止自动上传
- mimeType 探测:Safari 用 audio/mp4,Chrome 优先 audio/webm/opus
- 文件名 录音-YYYY-MM-DD-HH-MM-SS.{m4a|webm}
- 原 + 文件 入口保留小型,作为电脑端兜底
This commit is contained in:
Fam Zheng
2026-05-17 21:55:39 +01:00
parent c2c0c6999d
commit 44652eb398
+117 -7
View File
@@ -25,6 +25,17 @@
<button class="logout-btn" @click="logout" title="退出 / 改 passphrase"></button>
</header>
<div class="upload-row">
<button
v-if="recState === 'idle'"
class="rec-btn"
:disabled="uploading"
@click="startRec"
>🎙 直接录</button>
<button
v-else
class="rec-btn recording"
@click="stopRec"
> {{ fmtSec(recDuration) }}</button>
<label class="upload-pick">
<input
ref="fileInput"
@@ -32,7 +43,7 @@
accept="audio/*,video/*"
@change="onFile"
/>
<span class="upload-btn" :class="{ uploading }">{{ uploading ? '⏳ 上传中…' : ' 录音' }}</span>
<span class="upload-btn small" :class="{ uploading }">{{ uploading ? '⏳' : ' 文件' }}</span>
</label>
</div>
<p v-if="uploadErr" class="upload-err">{{ uploadErr }}</p>
@@ -121,6 +132,82 @@ const uploading = ref(false)
const uploadErr = ref('')
let pollTimer = null
// 浏览器内录音(iOS 没法选录音机 App 文件,直接 web record 更顺)
const recState = ref('idle') // 'idle' | 'recording'
const recDuration = ref(0)
let mediaRecorder = null
let recChunks = []
let recStream = null
let recTimer = null
async function startRec() {
uploadErr.value = ''
if (!navigator.mediaDevices?.getUserMedia) {
uploadErr.value = '浏览器不支持 mic 录音'
return
}
try {
recStream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (e) {
uploadErr.value = 'mic 权限被拒:' + (e.message || e.name)
return
}
// Safari 偏向 audio/mp4Chrome/Edge 优先 audio/webm
const tries = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', '']
let mimeType = ''
for (const t of tries) {
if (!t || (window.MediaRecorder && MediaRecorder.isTypeSupported(t))) {
mimeType = t
break
}
}
recChunks = []
mediaRecorder = mimeType
? new MediaRecorder(recStream, { mimeType })
: new MediaRecorder(recStream)
mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) recChunks.push(e.data) }
mediaRecorder.onstop = onRecStop
mediaRecorder.start(1000) // 1s chunks 保证 stop 时有数据
recState.value = 'recording'
recDuration.value = 0
recTimer = setInterval(() => recDuration.value++, 1000)
}
function stopRec() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
if (recTimer) { clearInterval(recTimer); recTimer = null }
}
async function onRecStop() {
const mimeType = mediaRecorder?.mimeType || 'audio/webm'
const blob = new Blob(recChunks, { type: mimeType })
if (recStream) {
recStream.getTracks().forEach(t => t.stop())
recStream = null
}
recState.value = 'idle'
// 生成文件名
const ext = mimeType.includes('mp4') ? 'm4a'
: mimeType.includes('webm') ? 'webm'
: mimeType.includes('ogg') ? 'ogg'
: 'bin'
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
const file = new File([blob], `录音-${ts}.${ext}`, { type: mimeType })
if (file.size < 1024) {
uploadErr.value = '录音太短(< 1KB),没保存'
return
}
await doUpload(file)
}
function fmtSec(s) {
const m = Math.floor(s / 60)
const sec = s % 60
return m + ':' + (sec < 10 ? '0' : '') + sec
}
async function submitPass() {
setPass(passDraft.value.trim())
try {
@@ -366,12 +453,11 @@ input, textarea { font-family: inherit; background: transparent; border: none; c
.upload-row {
padding: 12px;
border-bottom: 1px solid var(--border-soft);
display: flex;
gap: 8px;
}
.upload-pick { position: relative; display: block; cursor: pointer; }
.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-btn {
display: block;
text-align: center;
.rec-btn {
flex: 1;
background: var(--accent-strong);
color: #fff;
padding: 10px;
@@ -380,8 +466,32 @@ input, textarea { font-family: inherit; background: transparent; border: none; c
font-size: 13px;
transition: background 0.15s;
}
.upload-btn:hover { background: var(--accent); }
.rec-btn:hover:not(:disabled) { background: var(--accent); }
.rec-btn.recording {
background: var(--accent-red);
animation: rec-pulse 1.4s ease-in-out infinite;
}
@keyframes rec-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.upload-pick { position: relative; display: block; cursor: pointer; flex-shrink: 0; }
.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-btn {
display: block;
text-align: center;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text-dim);
padding: 10px 14px;
border-radius: 6px;
font-weight: 600;
font-size: 13px;
transition: background 0.15s;
}
.upload-btn:hover { background: var(--bg-hover); color: var(--text); }
.upload-btn.uploading { background: var(--bg-card); color: var(--text-dim); }
.upload-btn.small { padding: 10px 12px; }
.upload-err {
color: var(--accent-red);
font-size: 12px;