@@ -20,20 +20,29 @@
|
||||
<SvgIcon name="close" :size="40" color="var(--color-error)" />
|
||||
</button>
|
||||
|
||||
<div v-else class="voice-recorder__preview">
|
||||
<button type="button" class="voice-recorder__main-btn" @click="togglePlay">
|
||||
<SvgIcon
|
||||
:name="isPlaying ? 'close' : 'paper-plane-right'"
|
||||
:size="40"
|
||||
color="var(--color-thd-gray)"
|
||||
/>
|
||||
<div v-else class="voice-recorder__player">
|
||||
<button type="button" class="voice-recorder__play-btn" @click="togglePlay">
|
||||
<SvgIcon :name="isPlaying ? 'pause' : 'play'" :size="24" color="#848484" />
|
||||
</button>
|
||||
<div
|
||||
ref="trackEl"
|
||||
class="voice-recorder__track"
|
||||
@pointerdown="onSeekStart"
|
||||
@pointermove="onSeekMove"
|
||||
@pointerup="onSeekEnd"
|
||||
@pointercancel="onSeekEnd"
|
||||
>
|
||||
<div class="voice-recorder__track-fill" :style="{ width: `${progress}%` }" />
|
||||
<span class="voice-recorder__knob" :style="{ left: `${progress}%` }" />
|
||||
</div>
|
||||
<span class="voice-recorder__time">{{ timeLabel }}</span>
|
||||
<audio
|
||||
ref="audioEl"
|
||||
:src="audioUrl"
|
||||
class="voice-recorder__audio"
|
||||
@ended="onEnded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,10 +84,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="audioUrl" class="voice-recorder__progress">
|
||||
<div class="voice-recorder__progress-fill" :style="{ width: `${progress}%` }" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="voice-recorder__error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
@@ -113,8 +118,12 @@ const isRecording = ref(false)
|
||||
const isPlaying = ref(false)
|
||||
const elapsedMs = ref(0)
|
||||
const progress = ref(0)
|
||||
const duration = ref(0)
|
||||
const currentSeconds = ref(0)
|
||||
const audioEl = ref(null)
|
||||
const trackEl = ref(null)
|
||||
const fileInput = ref(null)
|
||||
let seeking = false
|
||||
|
||||
let mediaRecorder = null
|
||||
let mediaStream = null
|
||||
@@ -171,6 +180,8 @@ const setRecording = (file) => {
|
||||
audioFile.value = file
|
||||
audioUrl.value = URL.createObjectURL(file)
|
||||
progress.value = 0
|
||||
duration.value = 0
|
||||
currentSeconds.value = 0
|
||||
isPlaying.value = false
|
||||
emit('update:modelValue', file)
|
||||
emit('recorded', file)
|
||||
@@ -258,12 +269,71 @@ const togglePlay = () => {
|
||||
const onEnded = () => {
|
||||
isPlaying.value = false
|
||||
progress.value = 0
|
||||
currentSeconds.value = 0
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const a = audioEl.value
|
||||
if (!a?.duration) return
|
||||
progress.value = (a.currentTime / a.duration) * 100
|
||||
if (!a || !Number.isFinite(duration.value) || duration.value <= 0) return
|
||||
currentSeconds.value = a.currentTime
|
||||
progress.value = (a.currentTime / duration.value) * 100
|
||||
}
|
||||
|
||||
// MediaRecorder blobs report `Infinity` duration in Chromium until the element
|
||||
// is seeked past the end once — force that, then rewind.
|
||||
const onLoadedMetadata = () => {
|
||||
const a = audioEl.value
|
||||
if (!a) return
|
||||
if (Number.isFinite(a.duration)) {
|
||||
duration.value = a.duration
|
||||
return
|
||||
}
|
||||
const restore = () => {
|
||||
if (!Number.isFinite(a.duration)) return
|
||||
duration.value = a.duration
|
||||
a.currentTime = 0
|
||||
a.removeEventListener('timeupdate', restore)
|
||||
}
|
||||
a.addEventListener('timeupdate', restore)
|
||||
a.currentTime = 1e10
|
||||
}
|
||||
|
||||
const formatClock = (secs) => {
|
||||
if (!Number.isFinite(secs) || secs < 0) return '00:00'
|
||||
const total = Math.floor(secs)
|
||||
const h = Math.floor(total / 3600)
|
||||
const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
|
||||
const ss = String(total % 60).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
const timeLabel = computed(() =>
|
||||
formatClock(isPlaying.value || currentSeconds.value > 0 ? currentSeconds.value : duration.value)
|
||||
)
|
||||
|
||||
const seekTo = (clientX) => {
|
||||
const a = audioEl.value
|
||||
const track = trackEl.value
|
||||
if (!a || !track || !Number.isFinite(duration.value) || duration.value <= 0) return
|
||||
const rect = track.getBoundingClientRect()
|
||||
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
|
||||
a.currentTime = ratio * duration.value
|
||||
currentSeconds.value = a.currentTime
|
||||
progress.value = ratio * 100
|
||||
}
|
||||
|
||||
const onSeekStart = (event) => {
|
||||
seeking = true
|
||||
trackEl.value?.setPointerCapture?.(event.pointerId)
|
||||
seekTo(event.clientX)
|
||||
}
|
||||
|
||||
const onSeekMove = (event) => {
|
||||
if (seeking) seekTo(event.clientX)
|
||||
}
|
||||
|
||||
const onSeekEnd = () => {
|
||||
seeking = false
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -338,11 +408,76 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&__preview {
|
||||
&__player {
|
||||
direction: ltr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&__play-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: #e8e8e8;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.625rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__track-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: #5d5d5d;
|
||||
}
|
||||
|
||||
&__knob {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
background: #4b4b4b;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
color: #b5b5b5;
|
||||
flex-shrink: 0;
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__audio {
|
||||
@@ -406,21 +541,6 @@ onBeforeUnmount(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__progress {
|
||||
width: 100%;
|
||||
height: 0.125rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-thd-gray);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 40%);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-fa);
|
||||
|
||||
Vendored
+1
@@ -41,6 +41,7 @@ export type IconName =
|
||||
| 'newspaper'
|
||||
| 'paper-plane'
|
||||
| 'paper-plane-right'
|
||||
| 'pause'
|
||||
| 'pencil'
|
||||
| 'phone'
|
||||
| 'play'
|
||||
|
||||
Reference in New Issue
Block a user