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>
|
<button class="logout-btn" @click="logout" title="退出 / 改 passphrase">⎋</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="upload-row">
|
<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">
|
<label class="upload-pick">
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
@@ -32,7 +43,7 @@
|
|||||||
accept="audio/*,video/*"
|
accept="audio/*,video/*"
|
||||||
@change="onFile"
|
@change="onFile"
|
||||||
/>
|
/>
|
||||||
<span class="upload-btn" :class="{ uploading }">{{ uploading ? '⏳ 上传中…' : '+ 录音' }}</span>
|
<span class="upload-btn small" :class="{ uploading }">{{ uploading ? '⏳' : '+ 文件' }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="uploadErr" class="upload-err">{{ uploadErr }}</p>
|
<p v-if="uploadErr" class="upload-err">{{ uploadErr }}</p>
|
||||||
@@ -121,6 +132,82 @@ const uploading = ref(false)
|
|||||||
const uploadErr = ref('')
|
const uploadErr = ref('')
|
||||||
let pollTimer = null
|
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() {
|
async function submitPass() {
|
||||||
setPass(passDraft.value.trim())
|
setPass(passDraft.value.trim())
|
||||||
try {
|
try {
|
||||||
@@ -366,12 +453,11 @@ input, textarea { font-family: inherit; background: transparent; border: none; c
|
|||||||
.upload-row {
|
.upload-row {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--border-soft);
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.upload-pick { position: relative; display: block; cursor: pointer; }
|
.rec-btn {
|
||||||
.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
flex: 1;
|
||||||
.upload-btn {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
background: var(--accent-strong);
|
background: var(--accent-strong);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -380,8 +466,32 @@ input, textarea { font-family: inherit; background: transparent; border: none; c
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: background 0.15s;
|
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.uploading { background: var(--bg-card); color: var(--text-dim); }
|
||||||
|
.upload-btn.small { padding: 10px 12px; }
|
||||||
.upload-err {
|
.upload-err {
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user