feat:improve the audio function of the article

This commit is contained in:
zyronon
2025-09-14 16:41:33 +08:00
parent e921d00a15
commit 3b5d8d94ed
10 changed files with 189 additions and 276 deletions

1
components.d.ts vendored
View File

@@ -16,6 +16,7 @@ declare module 'vue' {
IconBxVolume: typeof import('~icons/bx/volume')['default']
IconBxVolumeFull: typeof import('~icons/bx/volume-full')['default']
IconBxVolumeLow: typeof import('~icons/bx/volume-low')['default']
IconBxVolumeMute: typeof import('~icons/bx/volume-mute')['default']
IconEosIconsLoading: typeof import('~icons/eos-icons/loading')['default']
IconFluentAdd16Filled: typeof import('~icons/fluent/add16-filled')['default']
IconFluentAdd16Regular: typeof import('~icons/fluent/add16-regular')['default']

View File

@@ -834,12 +834,13 @@
"id": "dWA516",
"title": "Do you call that a hat?",
"titleTranslate": "你把那个叫帽子吗?",
"text": "Do you call that a hat? I said to my wife. \nYou neednt be so rude about it, my wife answered as she looked at herself in the mirror. \nI sat down on one of those modern chairs with holes in it and waited. We had been in the hat shop for half an hour and my wife was still in front of the mirror. \nWe mustnt buy things we dont need, I remarked suddenly. I regretted saying it almost at once. \nYou neednt have said that, my wife answered. I neednt remind you of that terrible tie you bought yesterday. \nI find it beautiful, I said. A man can never have too many ties. \nAnd a woman cant have too many hats, she answered. \nTen minutes later we walked out of the shop together. My wife was wearing a hat that looked like a lighthouse!",
"textTranslate": "“你把那个叫帽子吗?”我对妻子说。 \n“你说话没必要这样不客气”我的妻子边回答边照着镜子。 \n我坐在一个新式的满是网眼儿的椅子上等待着。我们在这家帽店已经呆了半个小时了而我的妻子仍在镜子面前。 \n“我们不应该买我们不需要的东西”我突然发表意见说但马上又后悔说了这话。 \n“你没必要这么说”我妻子回答说“我也不必提醒你昨天买的那条糟糕透了的领带。” \n“我觉得它好看”我说“男人有多少领带也不会嫌多。” \n“女人有多少帽子也不嫌多。”她回答。 \n10分钟以后我们一道走出了商店。我妻子戴着一顶像灯塔一样的帽子。",
"text": "Do you call that a hat? I said to my wife. \nYou neednt be so rude about it, my wife answered as she looked at herself in the mirror. \nI sat down on one of those modern chairs with holes in it and waited. \nWe had been in the hat shop for half an hour and my wife was still in front of the mirror. \nWe mustnt buy things we dont need, I remarked suddenly. \nI regretted saying it almost at once. \nYou neednt have said that, my wife answered. \nI neednt remind you of that terrible tie you bought yesterday. \nI find it beautiful, I said. \nA man can never have too many ties. \nAnd a woman cant have too many hats, she answered. \nTen minutes later we walked out of the shop together. \nMy wife was wearing a hat that looked like a lighthouse!",
"textTranslate": "“你把那个叫帽子吗?”我对妻子说。 \n“你说话没必要这样不客气”我的妻子边回答边照着镜子。 \n我坐在一个新式的满是网眼儿的椅子上等待着。 \n我们在这家帽店已经呆了半个小时了,而我的妻子仍在镜子面前。 \n“我们不应该买我们不需要的东西”我突然发表意见说 \n但马上又后悔说了这话。 \n“你没必要这么说”我妻子回答说 \n“我也不必提醒你昨天买的那条糟糕透了的领带。” \n“我觉得它好看”我说 \n“男人有多少领带也不会嫌多。” \n“女人有多少帽子也不嫌多。”她回答。 \n10分钟以后我们一道走出了商店。 \n我妻子戴着一顶像灯塔一样的帽子。",
"newWords": [],
"textAllWords": [],
"audioSrc": "",
"lrcPosition": []
"lrcPosition": [],
"audioFileId": "TNJNgS"
},
{
"id": "XrDkqH",

View File

@@ -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);

View File

@@ -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)

View File

@@ -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">

View File

@@ -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"/>

View 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>

View File

@@ -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>

View File

@@ -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>
2LRC 文件用于解析句子对应音频的位置不一定准确后续可自行修改
</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>

View File

@@ -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>