feat: load images and audio files with retry (#160)
This commit is contained in:
parent
1969596a8e
commit
742d6d6195
@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
@ -11,7 +12,6 @@ import { downloadFile } from '@chatwoot/utils';
|
||||
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||
@ -20,14 +20,18 @@ const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const hasError = ref(false);
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'image',
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
|
||||
const handleError = () => {
|
||||
hasError.value = true;
|
||||
emit('error');
|
||||
};
|
||||
onMounted(() => {
|
||||
if (attachment.value?.dataUrl) {
|
||||
loadWithRetry(attachment.value.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const downloadAttachment = async () => {
|
||||
const { fileType, dataUrl, extension } = attachment.value;
|
||||
@ -40,6 +44,10 @@ const downloadAttachment = async () => {
|
||||
isDownloading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
hasError.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -54,14 +62,12 @@ const downloadAttachment = async () => {
|
||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="relative group rounded-lg overflow-hidden">
|
||||
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
|
||||
<img
|
||||
class="skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
:width="attachment.width"
|
||||
:height="attachment.height"
|
||||
@click="onClick"
|
||||
@error="handleError"
|
||||
/>
|
||||
<div
|
||||
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
||||
@ -86,7 +92,7 @@ const downloadAttachment = async () => {
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
@error="handleError"
|
||||
@error="handleImageError"
|
||||
@close="() => (showGallery = false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
@ -23,6 +25,11 @@ defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
|
||||
type: 'audio',
|
||||
});
|
||||
|
||||
const timeStampURL = computed(() => {
|
||||
return timeStampAppendedURL(attachment.dataUrl);
|
||||
});
|
||||
@ -38,19 +45,20 @@ const playbackSpeed = ref(1);
|
||||
const { uid } = getCurrentInstance();
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
if (audioPlayer.value) {
|
||||
duration.value = audioPlayer.value.duration;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
}
|
||||
};
|
||||
|
||||
const playbackSpeedLabel = computed(() => {
|
||||
return `${playbackSpeed.value}x`;
|
||||
});
|
||||
|
||||
// There maybe a chance that the audioPlayer ref is not available
|
||||
// When the onLoadMetadata is called, so we need to set the duration
|
||||
// value when the component is mounted
|
||||
onMounted(() => {
|
||||
duration.value = audioPlayer.value?.duration;
|
||||
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||
if (attachment.dataUrl) {
|
||||
loadWithRetry(attachment.dataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for global audio play events and pause if it's not this audio
|
||||
@ -121,71 +129,83 @@ const downloadAudio = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
<div
|
||||
v-if="hasError"
|
||||
v-bind="$attrs"
|
||||
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
|
||||
class="flex items-center gap-1 text-center rounded-lg p-2 bg-n-alpha-white border border-n-container"
|
||||
>
|
||||
<div class="flex gap-1 w-full flex-1 items-center justify-start">
|
||||
<button class="p-0 border-0 size-8" @click="playOrPause">
|
||||
<Icon
|
||||
v-if="isPlaying"
|
||||
class="size-8"
|
||||
icon="i-teenyicons-pause-small-solid"
|
||||
/>
|
||||
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
|
||||
</button>
|
||||
<div class="tabular-nums text-xs">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</div>
|
||||
<div class="flex-1 items-center flex px-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration"
|
||||
:value="currentTime"
|
||||
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
|
||||
@input="seek"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
|
||||
@click="changePlaybackSpeed"
|
||||
>
|
||||
<span class="text-xs text-n-slate-11 font-medium">
|
||||
{{ playbackSpeedLabel }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-0 border-0 size-8 grid place-content-center"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
|
||||
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
|
||||
</button>
|
||||
<button
|
||||
class="p-0 border-0 size-8 grid place-content-center"
|
||||
@click="downloadAudio"
|
||||
>
|
||||
<Icon class="size-4" icon="i-lucide-download" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attachment.transcribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11 text-sm">
|
||||
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else-if="isLoaded">
|
||||
<audio
|
||||
ref="audioPlayer"
|
||||
controls
|
||||
class="hidden"
|
||||
playsinline
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onEnd"
|
||||
>
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
|
||||
>
|
||||
<div class="flex gap-1 w-full flex-1 items-center justify-start">
|
||||
<button class="p-0 border-0 size-8" @click="playOrPause">
|
||||
<Icon
|
||||
v-if="isPlaying"
|
||||
class="size-8"
|
||||
icon="i-teenyicons-pause-small-solid"
|
||||
/>
|
||||
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
|
||||
</button>
|
||||
<div class="tabular-nums text-xs">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</div>
|
||||
<div class="flex-1 items-center flex px-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="duration"
|
||||
:value="currentTime"
|
||||
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
|
||||
@input="seek"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
|
||||
@click="changePlaybackSpeed"
|
||||
>
|
||||
<span class="text-xs text-n-slate-11 font-medium">
|
||||
{{ playbackSpeedLabel }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-0 border-0 size-8 grid place-content-center"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
|
||||
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
|
||||
</button>
|
||||
<button
|
||||
class="p-0 border-0 size-8 grid place-content-center"
|
||||
@click="downloadAudio"
|
||||
>
|
||||
<Icon class="size-4" icon="i-lucide-download" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attachment.transcribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
66
app/javascript/dashboard/composables/loadWithRetry.js
Normal file
66
app/javascript/dashboard/composables/loadWithRetry.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useLoadWithRetry = (config = {}) => {
|
||||
const maxRetry = config.maxRetry || 3;
|
||||
const backoff = config.backoff || 1000;
|
||||
const type = config.type || '';
|
||||
|
||||
const isLoaded = ref(false);
|
||||
const hasError = ref(false);
|
||||
|
||||
const loadWithRetry = async url => {
|
||||
const attemptLoad = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let media;
|
||||
if (type === 'image') {
|
||||
media = new Image();
|
||||
media.onload = () => resolve();
|
||||
media.onerror = () => reject(new Error('Failed to load image'));
|
||||
} else if (type === 'audio') {
|
||||
media = new Audio();
|
||||
media.onloadedmetadata = () => resolve();
|
||||
media.onerror = () => reject(new Error('Failed to load audio'));
|
||||
} else {
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (res.ok) resolve();
|
||||
else reject(new Error('Failed to load resource'));
|
||||
})
|
||||
.catch(err => reject(err));
|
||||
return;
|
||||
}
|
||||
media.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const sleep = ms => {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
};
|
||||
|
||||
const retry = async (attempt = 0) => {
|
||||
try {
|
||||
await attemptLoad();
|
||||
hasError.value = false;
|
||||
isLoaded.value = true;
|
||||
} catch (error) {
|
||||
if (attempt + 1 >= maxRetry) {
|
||||
hasError.value = true;
|
||||
isLoaded.value = false;
|
||||
return;
|
||||
}
|
||||
await sleep(backoff * (attempt + 1));
|
||||
await retry(attempt + 1);
|
||||
}
|
||||
};
|
||||
|
||||
await retry();
|
||||
};
|
||||
|
||||
return {
|
||||
isLoaded,
|
||||
hasError,
|
||||
loadWithRetry,
|
||||
};
|
||||
};
|
||||
@ -302,6 +302,7 @@
|
||||
},
|
||||
"MEDIA": {
|
||||
"IMAGE_UNAVAILABLE": "This image is no longer available.",
|
||||
"AUDIO_UNAVAILABLE": "This audio is no longer available.",
|
||||
"LOADING_FAILED": "Loading failed"
|
||||
}
|
||||
},
|
||||
|
||||
@ -302,6 +302,7 @@
|
||||
},
|
||||
"MEDIA": {
|
||||
"IMAGE_UNAVAILABLE": "Esta imagem não está mais disponível.",
|
||||
"AUDIO_UNAVAILABLE": "Este áudio não está mais disponível.",
|
||||
"LOADING_FAILED": "Falha no carregamento"
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user