@@ -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">
|
<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)">
|
<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>
|
</g>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="clip0_1_21269">
|
<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)" />
|
<SvgIcon name="close" :size="40" color="var(--color-error)" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-else class="voice-recorder__preview">
|
<div v-else class="voice-recorder__player">
|
||||||
<button type="button" class="voice-recorder__main-btn" @click="togglePlay">
|
<button type="button" class="voice-recorder__play-btn" @click="togglePlay">
|
||||||
<SvgIcon
|
<SvgIcon :name="isPlaying ? 'pause' : 'play'" :size="24" color="#848484" />
|
||||||
:name="isPlaying ? 'close' : 'paper-plane-right'"
|
|
||||||
:size="40"
|
|
||||||
color="var(--color-thd-gray)"
|
|
||||||
/>
|
|
||||||
</button>
|
</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
|
<audio
|
||||||
ref="audioEl"
|
ref="audioEl"
|
||||||
:src="audioUrl"
|
:src="audioUrl"
|
||||||
class="voice-recorder__audio"
|
class="voice-recorder__audio"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
@timeupdate="onTimeUpdate"
|
@timeupdate="onTimeUpdate"
|
||||||
|
@loadedmetadata="onLoadedMetadata"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,10 +84,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<p v-if="error" class="voice-recorder__error">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,8 +118,12 @@ const isRecording = ref(false)
|
|||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const elapsedMs = ref(0)
|
const elapsedMs = ref(0)
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
|
const duration = ref(0)
|
||||||
|
const currentSeconds = ref(0)
|
||||||
const audioEl = ref(null)
|
const audioEl = ref(null)
|
||||||
|
const trackEl = ref(null)
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
let seeking = false
|
||||||
|
|
||||||
let mediaRecorder = null
|
let mediaRecorder = null
|
||||||
let mediaStream = null
|
let mediaStream = null
|
||||||
@@ -171,6 +180,8 @@ const setRecording = (file) => {
|
|||||||
audioFile.value = file
|
audioFile.value = file
|
||||||
audioUrl.value = URL.createObjectURL(file)
|
audioUrl.value = URL.createObjectURL(file)
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
|
duration.value = 0
|
||||||
|
currentSeconds.value = 0
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
emit('update:modelValue', file)
|
emit('update:modelValue', file)
|
||||||
emit('recorded', file)
|
emit('recorded', file)
|
||||||
@@ -258,12 +269,71 @@ const togglePlay = () => {
|
|||||||
const onEnded = () => {
|
const onEnded = () => {
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
progress.value = 0
|
progress.value = 0
|
||||||
|
currentSeconds.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
const onTimeUpdate = () => {
|
||||||
const a = audioEl.value
|
const a = audioEl.value
|
||||||
if (!a?.duration) return
|
if (!a || !Number.isFinite(duration.value) || duration.value <= 0) return
|
||||||
progress.value = (a.currentTime / a.duration) * 100
|
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(
|
watch(
|
||||||
@@ -338,11 +408,76 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__preview {
|
&__player {
|
||||||
|
direction: ltr;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
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 {
|
&__audio {
|
||||||
@@ -406,21 +541,6 @@ onBeforeUnmount(() => {
|
|||||||
display: none;
|
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 {
|
&__error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-family-fa);
|
font-family: var(--font-family-fa);
|
||||||
|
|||||||
Vendored
+1
@@ -41,6 +41,7 @@ export type IconName =
|
|||||||
| 'newspaper'
|
| 'newspaper'
|
||||||
| 'paper-plane'
|
| 'paper-plane'
|
||||||
| 'paper-plane-right'
|
| 'paper-plane-right'
|
||||||
|
| 'pause'
|
||||||
| 'pencil'
|
| 'pencil'
|
||||||
| 'phone'
|
| 'phone'
|
||||||
| 'play'
|
| 'play'
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="activity-card__corner" />
|
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="activity-card__corner" />
|
||||||
|
|
||||||
<div class="activity-card__thumb">
|
<div class="activity-card__thumb">
|
||||||
<SvgIcon name="play" :size="'1.5rem'" />
|
<SvgIcon name="play" :size="'1.5rem'" color="#848484" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="activity-card__body">
|
<div class="activity-card__body">
|
||||||
|
|||||||
Reference in New Issue
Block a user