@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 3.75v12.5"/>
|
||||
<path d="M13 3.75v12.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 199 B |
@@ -1,6 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_21269)">
|
||||
<path d="M5.625 3.1154V16.8841C5.62704 16.994 5.65802 17.1014 5.7148 17.1955C5.77158 17.2896 5.85217 17.367 5.94843 17.42C6.0447 17.473 6.15323 17.4997 6.2631 17.4974C6.37297 17.4951 6.48028 17.4638 6.57422 17.4068L17.8305 10.5224C17.9204 10.468 17.9947 10.3913 18.0463 10.2998C18.0979 10.2082 18.1251 10.1049 18.1251 9.99977C18.1251 9.89467 18.0979 9.79135 18.0463 9.69979C17.9947 9.60823 17.9204 9.53154 17.8305 9.47711L6.57422 2.59274C6.48028 2.53571 6.37297 2.50447 6.2631 2.50214C6.15323 2.49982 6.0447 2.52651 5.94843 2.57951C5.85217 2.63252 5.77158 2.70996 5.7148 2.80405C5.65802 2.89813 5.62704 3.00552 5.625 3.1154Z" stroke="#848484" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.625 3.1154V16.8841C5.62704 16.994 5.65802 17.1014 5.7148 17.1955C5.77158 17.2896 5.85217 17.367 5.94843 17.42C6.0447 17.473 6.15323 17.4997 6.2631 17.4974C6.37297 17.4951 6.48028 17.4638 6.57422 17.4068L17.8305 10.5224C17.9204 10.468 17.9947 10.3913 18.0463 10.2998C18.0979 10.2082 18.1251 10.1049 18.1251 9.99977C18.1251 9.89467 18.0979 9.79135 18.0463 9.69979C17.9947 9.60823 17.9204 9.53154 17.8305 9.47711L6.57422 2.59274C6.48028 2.53571 6.37297 2.50447 6.2631 2.50214C6.15323 2.49982 6.0447 2.52651 5.94843 2.57951C5.85217 2.63252 5.77158 2.70996 5.7148 2.80405C5.65802 2.89813 5.62704 3.00552 5.625 3.1154Z" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_21269">
|
||||
|
||||
|
Before Width: | Height: | Size: 988 B After Width: | Height: | Size: 993 B |
@@ -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'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="activity-card__corner" />
|
||||
|
||||
<div class="activity-card__thumb">
|
||||
<SvgIcon name="play" :size="'1.5rem'" />
|
||||
<SvgIcon name="play" :size="'1.5rem'" color="#848484" />
|
||||
</div>
|
||||
|
||||
<div class="activity-card__body">
|
||||
|
||||
Reference in New Issue
Block a user