mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-02 19:28:56 +00:00
202 lines
5.3 KiB
Vue
202 lines
5.3 KiB
Vue
<script setup lang="ts">
|
|
import { Pause, Play } from 'lucide-vue-next'
|
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
|
|
|
import { useMessagesStore } from '@/stores/messages'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import type { SmsMessage } from '@/types/messages'
|
|
import { SkyRange } from '@/ui'
|
|
|
|
const props = defineProps<{ message: SmsMessage }>()
|
|
const phone = usePhoneStore()
|
|
const messages = useMessagesStore()
|
|
const audio = ref<HTMLAudioElement>()
|
|
const currentTime = ref(0)
|
|
const playing = ref(false)
|
|
const loading = ref(false)
|
|
const failed = ref(false)
|
|
const metadataDuration = ref(0)
|
|
let sourceLoadPromise: Promise<boolean> | undefined
|
|
const duration = computed(
|
|
() => metadataDuration.value || (props.message.media_duration_ms ?? 0) / 1000,
|
|
)
|
|
const progress = computed(() =>
|
|
duration.value > 0 ? Math.min(1, currentTime.value / duration.value) : 0,
|
|
)
|
|
const displayTime = computed(() =>
|
|
formatDuration(
|
|
playing.value || currentTime.value > 0 ? currentTime.value : duration.value,
|
|
),
|
|
)
|
|
const seekValueText = computed(() => formatDuration(currentTime.value))
|
|
const source = computed(() => messages.mediaSources[props.message.id] ?? '')
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const rounded = Math.max(0, Math.floor(seconds))
|
|
return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, '0')}`
|
|
}
|
|
|
|
async function ensureSource(): Promise<boolean> {
|
|
if (source.value) return Boolean(audio.value)
|
|
if (!sourceLoadPromise) {
|
|
loading.value = true
|
|
sourceLoadPromise = (async () => {
|
|
const loaded = await messages.loadMedia(props.message.id)
|
|
if (!loaded) {
|
|
failed.value = true
|
|
return false
|
|
}
|
|
await nextTick()
|
|
return Boolean(audio.value && source.value)
|
|
})().finally(() => {
|
|
loading.value = false
|
|
sourceLoadPromise = undefined
|
|
})
|
|
}
|
|
return sourceLoadPromise
|
|
}
|
|
|
|
async function togglePlayback(): Promise<void> {
|
|
if (loading.value) return
|
|
failed.value = false
|
|
if (playing.value) {
|
|
audio.value?.pause()
|
|
return
|
|
}
|
|
if (!(await ensureSource())) return
|
|
try {
|
|
await audio.value?.play()
|
|
} catch (error) {
|
|
failed.value = true
|
|
console.error(
|
|
`[Messages] Could not play audio message ${props.message.id}:`,
|
|
error,
|
|
)
|
|
}
|
|
}
|
|
|
|
async function seekPlayback(event: Event): Promise<void> {
|
|
const value = Number((event.target as HTMLInputElement).value)
|
|
failed.value = false
|
|
if (!Number.isFinite(value) || !(await ensureSource()) || !audio.value) return
|
|
const nextTime = Math.min(duration.value, Math.max(0, value))
|
|
audio.value.currentTime = nextTime
|
|
currentTime.value = nextTime
|
|
}
|
|
|
|
function updateProgress(): void {
|
|
currentTime.value = audio.value?.currentTime ?? 0
|
|
}
|
|
|
|
function finishPlayback(): void {
|
|
playing.value = false
|
|
currentTime.value = 0
|
|
}
|
|
|
|
function updateDuration(): void {
|
|
const value = audio.value?.duration ?? 0
|
|
if (Number.isFinite(value) && value > 0) metadataDuration.value = value
|
|
}
|
|
|
|
onBeforeUnmount(() => audio.value?.pause())
|
|
</script>
|
|
|
|
<template>
|
|
<div class="voice-message" :class="{ 'voice-message--failed': failed }">
|
|
<button
|
|
type="button"
|
|
class="voice-message__play"
|
|
:disabled="loading"
|
|
:aria-label="
|
|
phone.t(
|
|
playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio',
|
|
)
|
|
"
|
|
@click="togglePlayback"
|
|
>
|
|
<span v-if="loading" class="voice-message__loader" />
|
|
<Pause v-else-if="playing" :size="16" fill="currentColor" />
|
|
<Play v-else :size="16" fill="currentColor" />
|
|
</button>
|
|
<div class="voice-message__content">
|
|
<div class="voice-message__timeline">
|
|
<div class="voice-message__waveform" aria-hidden="true">
|
|
<i
|
|
v-for="(sample, index) in message.media_waveform ?? []"
|
|
:key="index"
|
|
:class="{
|
|
active:
|
|
index /
|
|
Math.max(1, (message.media_waveform?.length ?? 1) - 1) <=
|
|
progress,
|
|
}"
|
|
:style="{ height: `${Math.max(4, sample * 22)}px` }"
|
|
/>
|
|
</div>
|
|
<SkyRange
|
|
class="voice-message__range"
|
|
:model-value="currentTime"
|
|
:min="0"
|
|
:max="Math.max(0.1, duration)"
|
|
:step="0.1"
|
|
:aria-label="phone.t('Apps.messages.seekAudio')"
|
|
:aria-value-text="seekValueText"
|
|
@input="seekPlayback"
|
|
/>
|
|
</div>
|
|
<span>{{ displayTime }}</span>
|
|
</div>
|
|
<audio
|
|
ref="audio"
|
|
:src="source"
|
|
preload="metadata"
|
|
@loadedmetadata="updateDuration"
|
|
@play="playing = true"
|
|
@pause="playing = false"
|
|
@timeupdate="updateProgress"
|
|
@ended="finishPlayback"
|
|
@error="failed = true"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.voice-message__timeline {
|
|
position: relative;
|
|
min-width: 0;
|
|
height: 24px;
|
|
}
|
|
|
|
.voice-message__waveform {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
gap: 1px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.voice-message__waveform i {
|
|
width: auto;
|
|
min-width: 1px;
|
|
flex: 1 1 1px;
|
|
}
|
|
|
|
.voice-message__range {
|
|
position: absolute;
|
|
inset: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.voice-message__range :deep(.sky-range__input) {
|
|
width: 100%;
|
|
height: 24px;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.voice-message__timeline:focus-within {
|
|
border-radius: var(--sky-radius-pill);
|
|
outline: 2px solid currentColor;
|
|
outline-offset: 2px;
|
|
}
|
|
</style>
|