feat:improve the audio function of the article
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
@use "anim" as *;
|
||||
|
||||
:root {
|
||||
--color-reverse: blank;
|
||||
--color-reverse-white: white;
|
||||
--color-reverse-black: black;
|
||||
--color-item-bg: rgb(228, 230, 232);
|
||||
--color-item-hover: white;
|
||||
//--color-item-active: rgb(75, 110, 175);
|
||||
@@ -76,6 +77,9 @@
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--color-reverse-white: black;
|
||||
--color-reverse-black: white;
|
||||
|
||||
--color-primary: #0E1217;
|
||||
--color-second: rgb(30, 31, 34);
|
||||
--color-third: rgb(43, 45, 48);
|
||||
|
||||
@@ -555,7 +555,7 @@ export function usePlaySentenceAudio() {
|
||||
timer = setTimeout(() => {
|
||||
console.log('停')
|
||||
ref.pause()
|
||||
}, (end - start) * 1000)
|
||||
}, (end - start) / ref.playbackRate * 1000)
|
||||
}
|
||||
} else {
|
||||
playWordAudio(sentence.text)
|
||||
|
||||
@@ -16,7 +16,7 @@ import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {useArticleOptions} from "@/hooks/dict.ts";
|
||||
import {getDefaultArticle, getDefaultDict} from "@/types/func.ts";
|
||||
import Toast from "@/pages/pc/components/base/toast/Toast.ts";
|
||||
import Audio from "@/pages/pc/components/base/Audio.vue";
|
||||
import ArticleAudio from "@/pages/pc/article/components/ArticleAudio.vue";
|
||||
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const base = useBaseStore()
|
||||
@@ -135,10 +135,8 @@ const {
|
||||
<div class="right flex-[4] shrink-0 pl-4 overflow-auto">
|
||||
<div v-if="selectArticle.id">
|
||||
<div class="en-article-family title text-xl">
|
||||
<div class="text-center text-2xl" v-if="selectArticle.audioSrc">
|
||||
|
||||
<audio :src="selectArticle.audioSrc" controls></audio>
|
||||
<Audio :src="selectArticle.audioSrc" controls></Audio>
|
||||
<div class="text-center text-2xl my-2" v-if="selectArticle.audioSrc">
|
||||
<ArticleAudio :article="selectArticle"></ArticleAudio>
|
||||
</div>
|
||||
<div class="text-center text-2xl">{{ selectArticle.title }}</div>
|
||||
<div class="text-2xl" v-if="selectArticle.text">
|
||||
|
||||
@@ -24,6 +24,8 @@ import {useRoute, useRouter} from "vue-router";
|
||||
import book_list from "@/assets/book-list.json";
|
||||
import PracticeLayout from "@/pages/pc/components/PracticeLayout.vue";
|
||||
import Switch from "@/pages/pc/components/base/Switch.vue";
|
||||
import Audio from "@/pages/pc/components/base/Audio.vue";
|
||||
import ArticleAudio from "@/pages/pc/article/components/ArticleAudio.vue";
|
||||
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -361,7 +363,7 @@ function play2(e) {
|
||||
color="#999"/>
|
||||
</Tooltip>
|
||||
<div class="bottom">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<div class="stat">
|
||||
<div class="row">
|
||||
<div class="num">{{ speedMinute }}分钟</div>
|
||||
@@ -385,8 +387,7 @@ function play2(e) {
|
||||
<div class="name">单词总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<audio ref="audioRef" v-if="articleData.article.audioSrc" :src="articleData.article.audioSrc"
|
||||
controls></audio>
|
||||
<ArticleAudio ref="audioRef" :article="articleData.article"></ArticleAudio>
|
||||
<div class="flex flex-col items-center justify-center gap-1">
|
||||
<div class="flex gap-2 center">
|
||||
<Switch v-model="settingStore.articleSound"/>
|
||||
|
||||
62
src/pages/pc/article/components/ArticleAudio.vue
Normal file
62
src/pages/pc/article/components/ArticleAudio.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import {Article} from "@/types/types.ts";
|
||||
import {watch} from "vue";
|
||||
import {LOCAL_FILE_KEY} from "@/utils/const.ts";
|
||||
import {get} from "idb-keyval";
|
||||
import Audio from "@/pages/pc/components/base/Audio.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
article: Article
|
||||
}>()
|
||||
|
||||
let file = $ref(null)
|
||||
let instance = $ref<{ audioRef: HTMLAudioElement }>({audioRef: null})
|
||||
|
||||
watch(() => props.article.audioFileId, async () => {
|
||||
if (!props.article.audioSrc && props.article.audioFileId) {
|
||||
let list = await get(LOCAL_FILE_KEY)
|
||||
let rItem = list.find((file) => file.id === props.article.audioFileId)
|
||||
if (rItem) {
|
||||
file = URL.createObjectURL(rItem.file)
|
||||
}
|
||||
}else {
|
||||
file = null
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
//转发一遍,这里Proxy的默认值不能为{},可能是vue做了什么
|
||||
defineExpose(new Proxy({
|
||||
currentTime: 0,
|
||||
played: false,
|
||||
src: '',
|
||||
volume: 0,
|
||||
playbackRate: 1,
|
||||
play: () => void 0,
|
||||
pause: () => void 0,
|
||||
}, {
|
||||
get(target, key) {
|
||||
if (key === 'currentTime') return instance?.audioRef?.currentTime
|
||||
if (key === 'played') return instance?.audioRef?.played
|
||||
if (key === 'src') return instance?.audioRef?.src
|
||||
if (key === 'volume') return instance?.audioRef?.volume
|
||||
if (key === 'playbackRate') return instance?.audioRef?.playbackRate
|
||||
if (key === 'play') instance?.audioRef?.play()
|
||||
if (key === 'pause') instance?.audioRef?.pause()
|
||||
return target[key]
|
||||
},
|
||||
set(_, key, value) {
|
||||
if (key === 'currentTime') instance.audioRef.currentTime = value
|
||||
if (key === 'volume') return instance.audioRef.volume = value
|
||||
return true
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Audio v-bind="$attrs" ref="instance"
|
||||
v-if="props.article.audioSrc"
|
||||
:src="props.article.audioSrc"/>
|
||||
<Audio ref="instance" v-else-if="file"
|
||||
:src="file"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {Article} from "@/types/types.ts";
|
||||
import {watch, ref} from "vue";
|
||||
import {LOCAL_FILE_KEY} from "@/utils/const.ts";
|
||||
import {get} from "idb-keyval";
|
||||
|
||||
const props = defineProps<{
|
||||
article: Article
|
||||
}>()
|
||||
|
||||
let file = $ref(null)
|
||||
//这里不能用$ref,不然父组件获取不到
|
||||
let el = ref()
|
||||
|
||||
watch(() => props.article.audioFileId, async () => {
|
||||
if (!props.article.audioSrc && props.article.audioFileId) {
|
||||
let list = await get(LOCAL_FILE_KEY)
|
||||
let rItem = list.find((file) => file.id === props.article.audioFileId)
|
||||
if (rItem) {
|
||||
file = URL.createObjectURL(rItem.file)
|
||||
}
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
defineExpose({el})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<audio v-bind="$attrs" ref="el" v-if="props.article.audioSrc" :src="props.article.audioSrc" controls></audio>
|
||||
<audio ref="el" v-else-if="file" :src="file" controls></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
|
||||
</style>
|
||||
@@ -19,8 +19,7 @@ import InputNumber from "@/pages/pc/components/base/InputNumber.vue";
|
||||
import {nanoid} from "nanoid";
|
||||
import {update} from "idb-keyval";
|
||||
import {LOCAL_FILE_KEY} from "@/utils/const.ts";
|
||||
// import Audio from "@/pages/pc/article/components/Audio.vue";
|
||||
import Audio from "@/pages/pc/components/base/Audio.vue";
|
||||
import ArticleAudio from "@/pages/pc/article/components/ArticleAudio.vue";
|
||||
import BaseInput from "@/pages/pc/components/base/BaseInput.vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
|
||||
@@ -160,7 +159,6 @@ function save(option: 'save' | 'saveAndNext') {
|
||||
emit(option as any, editArticle)
|
||||
return resolve(true)
|
||||
}
|
||||
|
||||
saveTemp()
|
||||
})
|
||||
}
|
||||
@@ -237,15 +235,16 @@ let currentSentence = $ref<Sentence>({} as any)
|
||||
let editSentence = $ref<Sentence>({} as any)
|
||||
let preSentence = $ref<Sentence>({} as any)
|
||||
let showEditAudioDialog = $ref(false)
|
||||
let sentenceAudioRef = $ref<{ el: HTMLAudioElement }>({el: null})
|
||||
let audioRef = $ref<{ el: HTMLAudioElement }>({el: null})
|
||||
let showAudioDialog = $ref(false)
|
||||
let sentenceAudioRef = $ref<HTMLAudioElement>()
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
|
||||
function handleShowEditAudioDialog(val: Sentence, i: number, j: number) {
|
||||
showEditAudioDialog = true
|
||||
currentSentence = val
|
||||
editSentence = cloneDeep(val)
|
||||
preSentence = null
|
||||
audioRef.el.pause()
|
||||
audioRef.pause()
|
||||
if (j == 0) {
|
||||
if (i != 0) {
|
||||
preSentence = last(editArticle.sections[i - 1])
|
||||
@@ -260,25 +259,25 @@ function handleShowEditAudioDialog(val: Sentence, i: number, j: number) {
|
||||
}
|
||||
}
|
||||
_nextTick(() => {
|
||||
sentenceAudioRef.el.currentTime = editSentence.audioPosition[0]
|
||||
sentenceAudioRef.currentTime = editSentence.audioPosition[0]
|
||||
})
|
||||
}
|
||||
|
||||
function recordStart() {
|
||||
if (sentenceAudioRef.el.paused) {
|
||||
sentenceAudioRef.el.play()
|
||||
if (sentenceAudioRef.paused) {
|
||||
sentenceAudioRef.play()
|
||||
}
|
||||
editSentence.audioPosition[0] = Number(sentenceAudioRef.el.currentTime.toFixed(2))
|
||||
editSentence.audioPosition[0] = Number(sentenceAudioRef.currentTime.toFixed(2))
|
||||
if (editSentence.audioPosition[0] > editSentence.audioPosition[1]) {
|
||||
editSentence.audioPosition[1] = editSentence.audioPosition[0]
|
||||
}
|
||||
}
|
||||
|
||||
function recordEnd() {
|
||||
if (!sentenceAudioRef.el.paused) {
|
||||
sentenceAudioRef.el.pause()
|
||||
if (!sentenceAudioRef.paused) {
|
||||
sentenceAudioRef.pause()
|
||||
}
|
||||
editSentence.audioPosition[1] = Number(sentenceAudioRef.el.currentTime.toFixed(2))
|
||||
editSentence.audioPosition[1] = Number(sentenceAudioRef.currentTime.toFixed(2))
|
||||
}
|
||||
|
||||
const {playSentenceAudio} = usePlaySentenceAudio()
|
||||
@@ -290,7 +289,7 @@ function saveLrcPosition() {
|
||||
}
|
||||
|
||||
function jumpAudio(time: number) {
|
||||
sentenceAudioRef.el.currentTime = time
|
||||
sentenceAudioRef.currentTime = time
|
||||
}
|
||||
|
||||
function setPreEndTimeToCurrentStartTime() {
|
||||
@@ -311,17 +310,13 @@ function setStartTime(val: Sentence, i: number, j: number) {
|
||||
if (preSentence) {
|
||||
val.audioPosition[0] = preSentence.audioPosition[1]
|
||||
} else {
|
||||
val.audioPosition[0] = Number(Number(audioRef.el.currentTime).toFixed(2))
|
||||
val.audioPosition[0] = Number(Number(audioRef.currentTime).toFixed(2))
|
||||
}
|
||||
if (val.audioPosition[0] > val.audioPosition[1]) {
|
||||
val.audioPosition[1] = val.audioPosition[0]
|
||||
}
|
||||
}
|
||||
|
||||
function uploadFileTrigger(id: string) {
|
||||
(document.querySelector('#' + id) as HTMLDivElement)?.click()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -430,16 +425,8 @@ function uploadFileTrigger(id: string) {
|
||||
<div class="row flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<div class="title">结果</div>
|
||||
<div class="flex gap-2 flex-1">
|
||||
<div class="upload relative">
|
||||
<BaseButton>添加音频LRC文件</BaseButton>
|
||||
<input type="file"
|
||||
accept=".lrc"
|
||||
@change="handleChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"/>
|
||||
</div>
|
||||
<!-- <Audio ref="audioRef" :article="editArticle"/>-->
|
||||
<Audio ref="audioRef" :src="editArticle.audioSrc"/>
|
||||
<div class="flex gap-2 flex-1 justify-end">
|
||||
<ArticleAudio ref="audioRef" :article="editArticle"/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="editArticle?.sections?.length">
|
||||
@@ -450,16 +437,9 @@ function uploadFileTrigger(id: string) {
|
||||
<div>|</div>
|
||||
<div class="center flex-[3] gap-2">
|
||||
<span>音频</span>
|
||||
<BaseIcon title="添加音频"
|
||||
@click="uploadFileTrigger('updateFile1')"
|
||||
>
|
||||
<BaseIcon title="音频管理" @click="showAudioDialog = true">
|
||||
<IconIconParkOutlineAddMusic/>
|
||||
</BaseIcon>
|
||||
<input type="file"
|
||||
id="updateFile1"
|
||||
accept="audio/*"
|
||||
@change="handleAudioChange"
|
||||
class="w-0 h-0"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article-translate">
|
||||
@@ -495,7 +475,7 @@ function uploadFileTrigger(id: string) {
|
||||
<div v-if="sentence.audioPosition?.[1] !== -1">{{ sentence.audioPosition?.[1] ?? 0 }}s</div>
|
||||
<div v-else> 结束</div>
|
||||
<BaseIcon
|
||||
@click="sentence.audioPosition[1] = Number(Number(audioRef.el.currentTime).toFixed(2))"
|
||||
@click="sentence.audioPosition[1] = Number(Number(audioRef.currentTime).toFixed(2))"
|
||||
title="设置结束时间"
|
||||
>
|
||||
<IconFluentMyLocation20Regular/>
|
||||
@@ -513,7 +493,7 @@ function uploadFileTrigger(id: string) {
|
||||
<BaseIcon
|
||||
title="播放"
|
||||
v-if="sentence.audioPosition?.length"
|
||||
@click="playSentenceAudio(sentence,audioRef.el)">
|
||||
@click="playSentenceAudio(sentence,audioRef)">
|
||||
<IconFluentPlay20Regular/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
@@ -553,7 +533,7 @@ function uploadFileTrigger(id: string) {
|
||||
教程:点击音频播放按钮,当播放到句子开始时,点击开始时间的 <span class="color-red">记录</span>
|
||||
按钮;当播放到句子结束时,点击结束时间的 <span class="color-red">记录</span> 按钮,最后再试听是否正确
|
||||
</div>
|
||||
<Audio ref="sentenceAudioRef" :article="editArticle" class="w-full"/>
|
||||
<ArticleAudio ref="sentenceAudioRef" :article="editArticle" class="w-full"/>
|
||||
<div class="flex items-center gap-2 space-between mb-2" v-if="editSentence.audioPosition?.length">
|
||||
<div>{{ editSentence.text }}</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
@@ -564,7 +544,7 @@ function uploadFileTrigger(id: string) {
|
||||
</div>
|
||||
<BaseIcon
|
||||
title="播放"
|
||||
@click="playSentenceAudio(editSentence,sentenceAudioRef.el)">
|
||||
@click="playSentenceAudio(editSentence,sentenceAudioRef)">
|
||||
<IconFluentPlay20Regular/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
@@ -606,6 +586,35 @@ function uploadFileTrigger(id: string) {
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog title="音频管理"
|
||||
v-model="showAudioDialog"
|
||||
:footer="false"
|
||||
@close="showAudioDialog = false"
|
||||
>
|
||||
<div class="p-4 pt-0 color-main w-150 flex flex-col gap-2">
|
||||
<div class="">
|
||||
1、上传的文件保存在本地电脑上,更换电脑数据将丢失,请及时备份数据
|
||||
<br>
|
||||
2、LRC 文件用于解析句子对应音频的位置,不一定准确,后续可自行修改
|
||||
</div>
|
||||
<!-- <ArticleAudio ref="sentenceAudioRef" :article="editArticle" class="w-full"/>-->
|
||||
<div class="upload relative">
|
||||
<BaseButton>上传音频</BaseButton>
|
||||
<input type="file"
|
||||
accept="audio/*"
|
||||
@change="handleAudioChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"/>
|
||||
</div>
|
||||
<div class="upload relative">
|
||||
<BaseButton>上传 LRC 文件</BaseButton>
|
||||
<input type="file"
|
||||
accept=".lrc"
|
||||
@change="handleChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -72,29 +72,6 @@ const togglePlay = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const seekTo = (event: MouseEvent) => {
|
||||
if (!audioRef.value || !progressBarRef.value || props.disabled) return;
|
||||
|
||||
const rect = progressBarRef.value.getBoundingClientRect();
|
||||
const clickX = event.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
|
||||
const newTime = percentage * duration.value;
|
||||
|
||||
audioRef.value.currentTime = newTime;
|
||||
currentTime.value = newTime;
|
||||
};
|
||||
|
||||
const setVolume = (event: MouseEvent) => {
|
||||
if (!audioRef.value || !volumeBarRef.value || props.disabled) return;
|
||||
|
||||
const rect = volumeBarRef.value.getBoundingClientRect();
|
||||
const clickX = event.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
|
||||
|
||||
volume.value = percentage;
|
||||
audioRef.value.volume = percentage;
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!audioRef.value || props.disabled) return;
|
||||
|
||||
@@ -131,9 +108,6 @@ const handleLoadedMetadata = () => {
|
||||
duration.value = audioRef.value?.duration || 0;
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
};
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
};
|
||||
|
||||
@@ -282,14 +256,14 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
|
||||
|
||||
// 立即跳转到点击位置
|
||||
const clickY = event.clientY - rect.top;
|
||||
// 反转百分比,因为我们希望底部是0%,顶部是100%
|
||||
const percentage = Math.max(0, Math.min(1, 1 - (clickY / rect.height)));
|
||||
// 计算百分比,最上面是0%,最下面是100%
|
||||
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
|
||||
|
||||
// 直接更新DOM样式
|
||||
if (volumeFill && volumeThumb) {
|
||||
volumeFill.style.height = `${percentage * 100}%`;
|
||||
// 设置bottom而不是left
|
||||
volumeThumb.style.bottom = `${percentage * 100}%`;
|
||||
// 设置top而不是bottom
|
||||
volumeThumb.style.top = `${percentage * 100}%`;
|
||||
// 重置left样式
|
||||
volumeThumb.style.left = '50%';
|
||||
}
|
||||
@@ -316,14 +290,14 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
|
||||
|
||||
const rect = volumeBarRef.value!.getBoundingClientRect();
|
||||
const clickY = e.clientY - rect.top;
|
||||
// 反转百分比,因为我们希望底部是0%,顶部是100%
|
||||
const percentage = Math.max(0, Math.min(1, 1 - (clickY / rect.height)));
|
||||
// 计算百分比,最上面是0%,最下面是100%
|
||||
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
|
||||
|
||||
// 直接更新DOM样式,不使用响应式变量
|
||||
if (volumeFill && volumeThumb) {
|
||||
volumeFill.style.height = `${percentage * 100}%`;
|
||||
// 设置bottom而不是left
|
||||
volumeThumb.style.bottom = `${percentage * 100}%`;
|
||||
// 设置top而不是bottom
|
||||
volumeThumb.style.top = `${percentage * 100}%`;
|
||||
}
|
||||
|
||||
// 更新响应式变量和音频音量
|
||||
@@ -357,10 +331,6 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const endVolumeDrag = () => {
|
||||
isVolumeDragging.value = false;
|
||||
};
|
||||
|
||||
// 监听属性变化
|
||||
watch(() => props.src, (newSrc) => {
|
||||
if (audioRef.value) {
|
||||
@@ -403,12 +373,13 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({audioRef})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="custom-audio"
|
||||
:class="{ 'disabled': disabled, 'has-error': error }"
|
||||
:class="{ 'disabled': disabled||error, 'has-error': error }"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<!-- 隐藏的原生audio元素 -->
|
||||
@@ -422,7 +393,6 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
@loadstart="handleLoadStart"
|
||||
@loadeddata="handleLoadedData"
|
||||
@loadedmetadata="handleLoadedMetadata"
|
||||
@canplay="handleCanPlay"
|
||||
@canplaythrough="handleCanPlayThrough"
|
||||
@play="handlePlay"
|
||||
@pause="handlePause"
|
||||
@@ -438,7 +408,7 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
<!-- 播放/暂停按钮 -->
|
||||
<button
|
||||
class="play-button"
|
||||
:class="{ 'playing': isPlaying, 'loading': isLoading }"
|
||||
:class="{ 'loading': isLoading }"
|
||||
@click="togglePlay"
|
||||
:disabled="disabled"
|
||||
:aria-label="isPlaying ? '暂停' : '播放'"
|
||||
@@ -455,8 +425,7 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
<!-- 进度条区域 -->
|
||||
<div class="progress-section">
|
||||
<!-- 时间显示 -->
|
||||
<span class="time-display">{{ formatTime(currentTime) }}</span>
|
||||
|
||||
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="progress-container"
|
||||
@@ -475,8 +444,6 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 总时长 -->
|
||||
<span class="time-display">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 音量控制 -->
|
||||
@@ -491,17 +458,9 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
:disabled="disabled"
|
||||
:aria-label="volume > 0 ? '静音' : '取消静音'"
|
||||
>
|
||||
<svg v-if="volume === 0" class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 = 13.5 21 = 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 = 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
<svg v-else-if="volume < 0.5" class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
<svg v-else class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<IconBxVolumeMute v-if="volume === 0" class="icon"></IconBxVolumeMute>
|
||||
<IconBxVolumeLow v-else-if="volume < 0.5" class="icon"></IconBxVolumeLow>
|
||||
<IconBxVolumeFull v-else class="icon"></IconBxVolumeFull>
|
||||
</button>
|
||||
|
||||
<!-- 音量下拉控制条 -->
|
||||
@@ -514,11 +473,11 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
<div class="volume-track">
|
||||
<div
|
||||
class="volume-fill"
|
||||
:style="{ height: volumeProgress + '%' }"
|
||||
:style="{ height: volumeProgress + '%', top: 0 }"
|
||||
></div>
|
||||
<div
|
||||
class="volume-thumb"
|
||||
:style="{ bottom: volumeProgress + '%' }"
|
||||
:style="{ top: volumeProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,41 +502,30 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.custom-audio {
|
||||
// CSS变量定义,可以通过外部覆盖来自定义样式
|
||||
--audio-bg: #fff;
|
||||
--audio-border-radius: 8px;
|
||||
--audio-box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--audio-container-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--audio-text-color: white;
|
||||
--audio-box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);
|
||||
--audio-button-bg: rgba(255, 255, 255, 0.2);
|
||||
--audio-button-hover-bg: rgba(255, 255, 255, 0.3);
|
||||
--audio-button-playing-bg: rgba(255, 255, 255, 0.3);
|
||||
--audio-progress-bg: rgba(255, 255, 255, 0.3);
|
||||
--audio-progress-fill: rgba(255, 255, 255, 0.8);
|
||||
--audio-thumb-bg: white;
|
||||
--audio-thumb-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
--audio-volume-thumb-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
--audio-speed-button-bg: rgba(255, 255, 255, 0.1);
|
||||
--audio-speed-button-border: rgba(255, 255, 255, 0.3);
|
||||
--audio-speed-button-hover-bg: rgba(255, 255, 255, 0.2);
|
||||
--audio-speed-button-hover-border: rgba(255, 255, 255, 0.5);
|
||||
--audio-error-bg: #f56c6c;
|
||||
--audio-error-color: white;
|
||||
--height: 32px;
|
||||
--gap: 8px;
|
||||
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--audio-border-radius);
|
||||
box-shadow: var(--audio-box-shadow);
|
||||
color: black;
|
||||
//overflow: hidden;
|
||||
color: var(--color-reverse-black);
|
||||
transition: all 0.3s ease;
|
||||
font-family: var(--font-family);
|
||||
padding: 0.3rem 0.4rem;
|
||||
position: relative;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -596,26 +544,18 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
width: var(--height);
|
||||
height: var(--height);
|
||||
color: var(--color-reverse-black);
|
||||
border-radius: 50%;
|
||||
background: var(--color-second);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--audio-speed-button-border);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--audio-button-hover-bg);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.playing {
|
||||
background: var(--audio-button-playing-bg);
|
||||
&:hover {
|
||||
background: var(--color-card-active) !important;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
@@ -655,7 +595,6 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
|
||||
.progress-container {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
@@ -667,9 +606,7 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-second);
|
||||
//background: white;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@@ -683,8 +620,8 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--color-fourth);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--audio-thumb-shadow);
|
||||
@@ -709,17 +646,17 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
width: var(--height);
|
||||
height: var(--height);
|
||||
border-radius: 4px;
|
||||
background: var(--color-second);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
color: var(--color-reverse-black);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--audio-speed-button-border);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--audio-button-hover-bg);
|
||||
&:hover {
|
||||
background: var(--color-card-active);
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -747,17 +684,6 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--audio-container-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.volume-container {
|
||||
@@ -772,30 +698,30 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
|
||||
.volume-track {
|
||||
position: relative;
|
||||
width: 4px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
background: var(--audio-progress-bg);
|
||||
background: var(--color-second);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.volume-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: var(--fill-height);
|
||||
background: var(--audio-progress-fill);
|
||||
background: var(--color-fourth);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.volume-thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: var(--thumb-bottom);
|
||||
transform: translate(-50%, 50%);
|
||||
top: var(--thumb-top);
|
||||
transform: translate(-50%, -50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--audio-thumb-bg);
|
||||
background: var(--color-fourth);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--audio-volume-thumb-shadow);
|
||||
cursor: grab;
|
||||
@@ -808,28 +734,33 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
}
|
||||
|
||||
.speed-button {
|
||||
padding: 6px 12px;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--audio-speed-button-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-second);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: var(--height);
|
||||
cursor: pointer;
|
||||
color: var(--color-reverse-black);
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--audio-speed-button-hover-bg);
|
||||
border-color: var(--audio-speed-button-hover-border);
|
||||
&:hover {
|
||||
background: var(--color-card-active);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 8px 16px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 2.6rem;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--audio-error-bg);
|
||||
color: var(--audio-error-color);
|
||||
color: var(--color-reverse-white);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
border-radius: var(--audio-border-radius);
|
||||
}
|
||||
|
||||
// 动画
|
||||
@@ -841,60 +772,4 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 480px) {
|
||||
.custom-audio {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.audio-container {
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 11px;
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
.volume-section {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.volume-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
.icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-container {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.speed-button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user