notes(record): 加浏览器内直接录音(绕 iOS 录音机 App 文件不可见)
deploy notes / build-and-deploy (push) Successful in 2m11s
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:
@@ -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/mp4,Chrome/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;
|
||||
|
||||
Reference in New Issue
Block a user