2128 lines
72 KiB
Vue
2128 lines
72 KiB
Vue
<template>
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="show"
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
|
@click="closeDialog"
|
|
>
|
|
<Transition name="scale">
|
|
<div
|
|
v-if="show"
|
|
class="bg-bg-secondary border border-border-secondary rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]"
|
|
@click.stop
|
|
>
|
|
<!-- 头部 -->
|
|
<div class="flex items-center justify-between p-4 border-b border-border-secondary shrink-0">
|
|
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.title }}</h3>
|
|
<button
|
|
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
|
@click="closeDialog"
|
|
>
|
|
<CloseIcon class="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 内容区域 -->
|
|
<div class="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
|
<!-- 音质选择 -->
|
|
<section class="space-y-3">
|
|
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em] px-1"
|
|
>{{ locale.quality }}</label
|
|
>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button
|
|
v-for="option in extendedQualityOptions"
|
|
:key="option.value"
|
|
class="flex flex-col p-4 rounded-2xl border text-left transition-all relative overflow-hidden group"
|
|
:class="[
|
|
selectedQuality === option.value
|
|
? 'bg-primary-hover-10 border-primary shadow-sm'
|
|
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
|
|
]"
|
|
@click="selectedQuality = option.value"
|
|
>
|
|
<div class="flex items-center justify-between mb-1 relative z-10">
|
|
<span
|
|
class="text-xs font-bold transition-colors"
|
|
:class="selectedQuality === option.value ? 'text-primary' : 'text-text-primary'"
|
|
>{{ option.label }}</span
|
|
>
|
|
<div
|
|
v-if="selectedQuality === option.value"
|
|
class="w-1.5 h-1.5 bg-primary rounded-full shadow-[0_0_8px_var(--primary-50)]"
|
|
/>
|
|
</div>
|
|
<span class="text-[10px] text-text-tertiary relative z-10">{{
|
|
option.description
|
|
}}</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 高级选项 -->
|
|
<Transition name="expand">
|
|
<section v-if="selectedSongs.size > 0" class="space-y-3 overflow-hidden">
|
|
<div class="flex items-center gap-2 px-1">
|
|
<Settings2 class="w-3 h-3 text-text-tertiary" />
|
|
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em]"
|
|
>{{ locale.advancedOptions }}</label
|
|
>
|
|
</div>
|
|
|
|
<div class="bg-bg-primary border border-border-secondary rounded-2xl p-4 space-y-4">
|
|
<!-- 合并开关 -->
|
|
<div v-if="selectedSongs.size > 1" class="flex items-center justify-between">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs font-bold text-text-primary">{{ locale.mergeIntoOne }}</span>
|
|
<span class="text-[10px] text-text-tertiary">{{ locale.mergeDesc }}</span>
|
|
</div>
|
|
<button
|
|
class="w-10 h-6 rounded-full transition-colors relative"
|
|
:class="mergeSongs ? 'bg-primary-hover' : 'bg-bg-quaternary'"
|
|
@click="mergeSongs = !mergeSongs"
|
|
>
|
|
<div
|
|
class="absolute top-1 left-1 w-4 h-4 bg-bg-secondary rounded-full transition-transform"
|
|
:class="mergeSongs ? 'translate-x-4' : 'translate-x-0'"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 标准化选项 -->
|
|
<div
|
|
class="flex items-center justify-between"
|
|
:class="selectedSongs.size > 1 ? 'pt-3 border-t border-border-secondary-50' : ''"
|
|
>
|
|
<div class="flex flex-col">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs font-bold text-text-primary">{{ locale.normalizeAudio }}</span>
|
|
<span
|
|
v-if="normalizeAudio"
|
|
class="text-[10px] bg-primary-10 text-primary px-1.5 py-0.5 rounded border border-primary-20"
|
|
>Peak {{ targetDb }}dB</span
|
|
>
|
|
</div>
|
|
<span class="text-[10px] text-text-tertiary"
|
|
>{{ locale.normalizeDesc }}</span
|
|
>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
class="text-[10px] text-primary hover:text-primary transition-colors flex items-center gap-1"
|
|
:title="locale.saveDbPresetTitle"
|
|
@click="saveDbPreset"
|
|
>
|
|
<Save class="w-3 h-3" />
|
|
{{ showDbPresetSaved ? locale.saved : locale.savePreset }}
|
|
</button>
|
|
<button
|
|
class="w-10 h-6 rounded-full transition-colors relative"
|
|
:class="normalizeAudio ? 'bg-primary-hover' : 'bg-bg-quaternary'"
|
|
@click="normalizeAudio = !normalizeAudio"
|
|
>
|
|
<div
|
|
class="absolute top-1 left-1 w-4 h-4 bg-bg-secondary rounded-full transition-transform"
|
|
:class="normalizeAudio ? 'translate-x-4' : 'translate-x-0'"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 目标分贝设置 -->
|
|
<Transition name="expand">
|
|
<div v-if="normalizeAudio" class="pt-2">
|
|
<div class="flex items-center gap-3">
|
|
<Volume2 class="w-4 h-4 text-text-tertiary" />
|
|
<input
|
|
v-model.number="targetDb"
|
|
type="range"
|
|
min="-10"
|
|
max="0"
|
|
step="0.5"
|
|
class="flex-1 h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
|
|
>
|
|
<span class="text-xs font-mono text-text-secondary w-12 text-right"
|
|
>{{ targetDb }} dB</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- 导出格式设置 -->
|
|
<div class="space-y-4 pt-4 border-t border-border-secondary-50">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex flex-col">
|
|
<span class="text-xs font-bold text-text-primary">{{ locale.exportFormat }}</span>
|
|
<span class="text-[10px] text-text-tertiary">
|
|
{{
|
|
shouldMergeSongs
|
|
? locale.mergeNeedsFormat
|
|
: locale.transcodeSingle
|
|
}}
|
|
</span>
|
|
</div>
|
|
<button
|
|
v-if="!shouldMergeSongs"
|
|
class="w-10 h-6 rounded-full transition-colors relative"
|
|
:class="convertAudioFormat ? 'bg-primary-hover' : 'bg-bg-quaternary'"
|
|
@click="convertAudioFormat = !convertAudioFormat"
|
|
>
|
|
<div
|
|
class="absolute top-1 left-1 w-4 h-4 bg-bg-secondary rounded-full transition-transform"
|
|
:class="convertAudioFormat ? 'translate-x-4' : 'translate-x-0'"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 导出格式选择 -->
|
|
<div class="space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<Music class="w-3 h-3 text-text-tertiary" />
|
|
<span class="text-xs font-bold text-text-primary">{{ locale.format }}</span>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
:disabled="!shouldUseExportFormat"
|
|
class="flex-1 py-1.5 px-3 rounded-lg border text-[10px] font-bold transition-all"
|
|
:class="[
|
|
exportFormat === 'mp3'
|
|
? 'bg-primary-hover-10 border-primary text-primary'
|
|
: 'bg-bg-secondary border-border-secondary text-text-tertiary hover:border-border-tertiary',
|
|
!shouldUseExportFormat ? 'opacity-50 cursor-not-allowed' : ''
|
|
]"
|
|
@click="exportFormat = 'mp3'"
|
|
>
|
|
MP3
|
|
</button>
|
|
<button
|
|
:disabled="!shouldUseExportFormat"
|
|
class="flex-1 py-1.5 px-3 rounded-lg border text-[10px] font-bold transition-all"
|
|
:class="[
|
|
exportFormat === 'wav'
|
|
? 'bg-primary-hover-10 border-primary text-primary'
|
|
: 'bg-bg-secondary border-border-secondary text-text-tertiary hover:border-border-tertiary',
|
|
!shouldUseExportFormat ? 'opacity-50 cursor-not-allowed' : ''
|
|
]"
|
|
@click="exportFormat = 'wav'"
|
|
>
|
|
WAV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 自定义文件名 -->
|
|
<div v-if="shouldMergeSongs" class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<Edit3 class="w-3 h-3 text-text-tertiary" />
|
|
<span class="text-xs font-bold text-text-primary">{{ locale.customFilename }}</span>
|
|
</div>
|
|
<button
|
|
class="text-[10px] text-primary hover:text-primary transition-colors flex items-center gap-1"
|
|
:class="{ 'opacity-50 cursor-not-allowed': !customFilename }"
|
|
:title="locale.saveAsDefaultPreset"
|
|
@click="saveFilenamePreset"
|
|
>
|
|
<Save class="w-3 h-3" />
|
|
{{ showPresetSaved ? locale.saved : locale.savePreset }}
|
|
</button>
|
|
</div>
|
|
<div class="relative">
|
|
<input
|
|
v-model="customFilename"
|
|
type="text"
|
|
:placeholder="locale.filenamePlaceholder"
|
|
class="w-full bg-bg-secondary border border-border-secondary rounded-xl px-3 py-2 text-xs text-text-primary focus:outline-none focus:border-primary-50 transition-colors pr-8"
|
|
>
|
|
<!-- 快速插入占位符按钮 -->
|
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
|
<button
|
|
class="px-1.5 py-0.5 rounded bg-bg-tertiary text-[9px] text-text-tertiary hover:text-text-primary hover:bg-bg-quaternary transition-colors"
|
|
:title="locale.insertAllSongs"
|
|
@click="insertPlaceholder('{songs}')"
|
|
>
|
|
{songs}
|
|
</button>
|
|
<button
|
|
class="px-1.5 py-0.5 rounded bg-bg-tertiary text-[9px] text-text-tertiary hover:text-text-primary hover:bg-bg-quaternary transition-colors"
|
|
:title="locale.insertDate"
|
|
@click="insertPlaceholder('{date}')"
|
|
>
|
|
{date}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p class="text-[9px] text-text-disabled">
|
|
{{ locale.placeholders }}
|
|
<code
|
|
class="bg-bg-tertiary px-1 rounded text-text-tertiary cursor-pointer hover:text-primary"
|
|
@click="insertPlaceholder('{songs}')"
|
|
>{songs}</code
|
|
>
|
|
({{ locale.allSongs }}),
|
|
<code
|
|
class="bg-bg-tertiary px-1 rounded text-text-tertiary cursor-pointer hover:text-primary"
|
|
@click="insertPlaceholder('{date}')"
|
|
>{date}</code
|
|
>
|
|
({{ locale.date }})
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</Transition>
|
|
|
|
<!-- 歌曲列表 -->
|
|
<section class="space-y-3">
|
|
<div class="flex items-center justify-between px-1">
|
|
<div class="flex items-center gap-3">
|
|
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em]"
|
|
>{{ locale.songList }}</label
|
|
>
|
|
<div
|
|
v-if="estimatedTotalDuration.count > 0"
|
|
class="flex items-center gap-1.5 text-[10px] text-primary font-bold bg-primary-10 px-2 py-0.5 rounded-full border border-primary-20"
|
|
>
|
|
<Clock class="w-3 h-3" />
|
|
<span>{{ getLocaleText('estimatedDuration', '', formatDuration(estimatedTotalDuration.total)) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
v-if="selectedSongs.size > 0"
|
|
class="text-[10px] font-bold text-info hover:text-info transition-colors flex items-center gap-1"
|
|
:disabled="estimatingDuration"
|
|
:title="locale.estimateSelectedTitle"
|
|
@click="estimateSelectedDurations"
|
|
>
|
|
<Clock class="w-3 h-3" />
|
|
<span v-if="estimatingDuration">{{ locale.estimatingDuration }}</span>
|
|
<span v-else>{{ locale.estimateDuration }}</span>
|
|
</button>
|
|
<button
|
|
v-if="selectedSongs.size > 0"
|
|
class="text-[10px] font-bold text-text-tertiary hover:text-text-secondary transition-colors flex items-center gap-1"
|
|
:title="locale.preloadSelectedTitle"
|
|
@click="preloadSelectedSongs"
|
|
>
|
|
<DownloadCloud class="w-3 h-3" />
|
|
{{ locale.preloadSelected }}
|
|
</button>
|
|
<button
|
|
class="text-[10px] font-bold text-primary-80 hover:text-primary transition-colors"
|
|
@click="toggleSelectAll"
|
|
>
|
|
{{ isAllSelected ? locale.deselectAll : locale.selectAll }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="bg-bg-primary-50 border border-border-secondary-50 rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
|
|
>
|
|
<div
|
|
v-for="song in songs"
|
|
:key="song.id"
|
|
class="w-full flex items-center gap-3 p-3.5 hover:bg-bg-tertiary-30 transition-all text-left border-b border-border-secondary-30 last:border-0 group relative"
|
|
>
|
|
<!-- 预下载进度条背景 -->
|
|
<div
|
|
v-if="
|
|
(preloadedSongs.has(song.song.id) &&
|
|
preloadedSongs.get(song.song.id).loading) ||
|
|
activeDownloads.has(song.song.id)
|
|
"
|
|
class="absolute bottom-0 left-0 h-0.5 bg-primary-50 transition-all duration-300 ease-out"
|
|
:style="{
|
|
width: `${
|
|
(typeof activeDownloads.get(song.song.id) === 'number'
|
|
? activeDownloads.get(song.song.id)
|
|
: activeDownloads.get(song.song.id)?.progress) ||
|
|
preloadedSongs.get(song.song.id)?.progress ||
|
|
0
|
|
}%`
|
|
}"
|
|
/>
|
|
|
|
<button
|
|
class="flex items-center justify-center shrink-0 w-4 h-4 rounded border transition-all"
|
|
:class="[
|
|
selectedSongs.has(song.song.id)
|
|
? 'bg-primary-hover border-primary shadow-sm'
|
|
: 'bg-bg-secondary border-border-secondary group-hover:border-border-tertiary'
|
|
]"
|
|
@click="toggleSongSelection(song.song.id)"
|
|
>
|
|
<Check
|
|
v-if="selectedSongs.has(song.song.id)"
|
|
class="w-2.5 h-2.5 text-text-primary font-bold"
|
|
stroke-width="3"
|
|
/>
|
|
</button>
|
|
|
|
<div
|
|
class="flex-1 min-w-0 flex flex-col cursor-pointer"
|
|
@click="toggleSongSelection(song.song.id)"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-xs font-bold text-text-secondary truncate">{{ song.song.title }}</p>
|
|
<!-- 预下载标记(精确时长) -->
|
|
<div
|
|
v-if="getUsablePreload(song.song.id, selectedQuality)"
|
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded bg-success-10 border border-success-20"
|
|
>
|
|
<Check class="w-2 h-2 text-success" />
|
|
<span class="text-[9px] font-mono text-success">{{
|
|
formatDuration(getUsablePreload(song.song.id, selectedQuality).duration)
|
|
}}</span>
|
|
</div>
|
|
<!-- 数据库时长标记 -->
|
|
<div
|
|
v-else-if="song.song.durationSeconds > 0"
|
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded bg-bg-tertiary border border-border-secondary"
|
|
>
|
|
<span class="text-[9px] font-mono text-text-secondary">{{
|
|
formatDuration(song.song.durationSeconds)
|
|
}}</span>
|
|
</div>
|
|
<!-- API 预估标记 -->
|
|
<div
|
|
v-else-if="estimatedDurations.has(song.song.id)"
|
|
class="flex items-center gap-1 px-1.5 py-0.5 rounded bg-info-10 border border-info-20"
|
|
>
|
|
<Clock class="w-2 h-2 text-info" />
|
|
<span class="text-[9px] font-mono text-info">{{
|
|
formatDuration(estimatedDurations.get(song.song.id).durationSeconds)
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<p class="text-[10px] text-text-tertiary truncate">{{ song.song.artist }}</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<div class="text-[9px] font-mono text-text-disabled uppercase">
|
|
{{ getPlatformShortName(getSongPlatform(song.song)) }}
|
|
</div>
|
|
|
|
<!-- 单个预下载/删除按钮 -->
|
|
<button
|
|
v-if="getUsablePreload(song.song.id, selectedQuality)"
|
|
class="p-1.5 rounded-lg hover:bg-error-10 text-text-disabled hover:text-error transition-colors"
|
|
:title="locale.deleteCache"
|
|
@click.stop="removePreloaded(song.song.id)"
|
|
>
|
|
<Trash2 class="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="p-1.5 rounded-lg hover:bg-primary-10 text-text-disabled hover:text-primary transition-colors"
|
|
:title="locale.preloadSong"
|
|
@click.stop="preloadSong(song.song)"
|
|
>
|
|
<DownloadCloud class="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="songs.length === 0" class="p-8 text-center text-text-disabled text-[10px]">
|
|
{{ locale.emptySongs }}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 进度条 -->
|
|
<section
|
|
v-if="downloading || downloadedCount > 0"
|
|
class="space-y-3 pt-4 border-t border-border-secondary-50"
|
|
>
|
|
<div
|
|
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
|
|
>
|
|
<span class="text-text-tertiary">{{
|
|
currentTaskType === 'merge' ? locale.processProgress : locale.downloadProgress
|
|
}}</span>
|
|
<span class="text-primary">{{ downloadedCount }} / {{ totalCount }}</span>
|
|
</div>
|
|
<div
|
|
class="h-2 bg-bg-primary rounded-full overflow-hidden border border-border-secondary-50 relative"
|
|
>
|
|
<div
|
|
class="h-full bg-gradient-to-r from-primary to-primary-hover transition-all duration-300 ease-out relative overflow-hidden"
|
|
:style="{
|
|
width: `${totalCount > 0 ? (downloadedCount / totalCount) * 100 : 0}%`
|
|
}"
|
|
>
|
|
<div
|
|
class="absolute inset-0 bg-bg-secondary-20 animate-[shimmer_2s_infinite] -skew-x-12"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="text-[10px] text-text-tertiary truncate">
|
|
<template v-if="downloading">
|
|
<span v-if="processingStatus" class="text-primary animate-pulse">{{
|
|
processingStatus
|
|
}}</span>
|
|
<span v-else>{{
|
|
currentDownloadSong ? getLocaleText('downloadingSong', '', currentDownloadSong) : locale.preparing
|
|
}}</span>
|
|
</template>
|
|
<template v-else>
|
|
{{ downloadErrors.length > 0 ? locale.completeWithErrors : locale.complete }}
|
|
</template>
|
|
</div>
|
|
|
|
<!-- 错误信息 -->
|
|
<div
|
|
v-if="downloadErrors.length > 0"
|
|
class="bg-error-5 border border-error-10 rounded-xl p-3 space-y-2"
|
|
>
|
|
<div class="text-[10px] font-bold text-error flex items-center gap-2">
|
|
<AlertTriangle class="w-3 h-3" />
|
|
{{ getLocaleText('downloadFailedCount', '', downloadErrors.length) }}
|
|
</div>
|
|
<div class="max-h-[60px] overflow-y-auto custom-scrollbar space-y-1">
|
|
<div
|
|
v-for="error in downloadErrors"
|
|
:key="error.id"
|
|
class="text-[9px] text-error-70 truncate"
|
|
>
|
|
{{ error.title }} - {{ error.error }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- 底部按钮 -->
|
|
<div
|
|
class="p-4 border-t border-border-secondary bg-bg-secondary-50 flex items-center justify-between shrink-0"
|
|
>
|
|
<div class="text-[11px] font-black text-text-tertiary uppercase tracking-widest">
|
|
{{ getLocaleText('selectedSongsCount', '', selectedSongs.size) }}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-wider"
|
|
:disabled="downloading"
|
|
@click="closeDialog"
|
|
>
|
|
{{ locale.cancel }}
|
|
</button>
|
|
<button
|
|
v-if="!downloading && downloadedCount > 0"
|
|
class="px-6 py-2.5 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-bold rounded-xl border border-border-tertiary transition-all uppercase tracking-wider"
|
|
@click="closeDialog"
|
|
>
|
|
{{ locale.close }}
|
|
</button>
|
|
<button
|
|
v-else
|
|
:disabled="selectedSongs.size === 0 || downloading"
|
|
class="px-6 py-2.5 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all uppercase tracking-wider flex items-center gap-2"
|
|
@click="startDownload"
|
|
>
|
|
<Download v-if="!downloading" class="w-3.5 h-3.5" />
|
|
<AppSpinner :size="14" />
|
|
{{
|
|
downloading
|
|
? currentTaskType === 'merge'
|
|
? locale.processing
|
|
: locale.downloading
|
|
: shouldMergeSongs
|
|
? locale.startProcessing
|
|
: locale.startDownload
|
|
}}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch, reactive, onUnmounted } from 'vue'
|
|
import { useAudioQuality } from '~/composables/useAudioQuality'
|
|
import { getMusicUrlResult } from '~/utils/musicUrl'
|
|
import { useLocale } from '~/utils/locale'
|
|
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
|
import {
|
|
X as CloseIcon,
|
|
Check,
|
|
Download,
|
|
AlertTriangle,
|
|
Settings2,
|
|
Volume2,
|
|
Edit3,
|
|
Save,
|
|
Music,
|
|
DownloadCloud,
|
|
Trash2,
|
|
Clock
|
|
} from '@lucide/vue'
|
|
|
|
const props = defineProps({
|
|
show: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
songs: {
|
|
type: Array,
|
|
default: () => []
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['close'])
|
|
|
|
const { getQualityOptions, getQuality } = useAudioQuality()
|
|
const { admin } = useLocale()
|
|
const locale = computed(() => {
|
|
const base = admin.value?.songManagement?.songDownloadDialog || {}
|
|
const emptyText = () => ''
|
|
return useSafeLocale({
|
|
...base,
|
|
estimatedDuration: base.estimatedDuration || emptyText,
|
|
downloadingSong: base.downloadingSong || emptyText,
|
|
downloadFailedCount: base.downloadFailedCount || emptyText,
|
|
selectedSongsCount: base.selectedSongsCount || emptyText
|
|
})
|
|
})
|
|
const { format: formatLocale } = useLocaleText(locale)
|
|
const getLocaleText = (key, fallback = '', ...args) => formatLocale(locale.value?.[key], fallback, ...args)
|
|
|
|
const mergeSongs = ref(false)
|
|
const convertAudioFormat = ref(false)
|
|
const normalizeAudio = ref(false)
|
|
const targetDb = ref(-1.0)
|
|
const processingStatus = ref('')
|
|
const customFilename = ref('')
|
|
const showPresetSaved = ref(false)
|
|
const showDbPresetSaved = ref(false)
|
|
const exportFormat = ref('mp3')
|
|
// const saveIntermediateWav = ref(false) // 已移除
|
|
|
|
const neteaseQualityLevelMap = {
|
|
2: 'standard',
|
|
3: 'higher',
|
|
4: 'exhigh',
|
|
5: 'lossless',
|
|
6: 'hires',
|
|
9: 'jymaster'
|
|
}
|
|
|
|
// 生成带描述的音质选项
|
|
const extendedQualityOptions = computed(() => {
|
|
const options = getQualityOptions('netease')
|
|
return options.map((opt) => ({
|
|
...opt,
|
|
description: locale.value.qualityDescriptions?.[opt.value] || locale.value.qualityDescriptions?.default || '标准音质'
|
|
}))
|
|
})
|
|
|
|
const selectedQuality = ref(getQuality('netease'))
|
|
|
|
const selectedSongs = ref(new Set())
|
|
|
|
const isAllSelected = computed(() => {
|
|
return props.songs.length > 0 && selectedSongs.value.size === props.songs.length
|
|
})
|
|
|
|
const shouldMergeSongs = computed(() => {
|
|
return mergeSongs.value && selectedSongs.value.size > 1
|
|
})
|
|
|
|
const shouldUseExportFormat = computed(() => {
|
|
return shouldMergeSongs.value || convertAudioFormat.value || normalizeAudio.value
|
|
})
|
|
|
|
const downloading = ref(false)
|
|
const downloadedCount = ref(0)
|
|
const totalCount = ref(0)
|
|
const currentDownloadSong = ref('')
|
|
const downloadErrors = ref([])
|
|
// 预下载缓存映射
|
|
const preloadedSongs = reactive(new Map())
|
|
// 当前活动下载进度映射 (songId -> progress)
|
|
const activeDownloads = reactive(new Map())
|
|
const activeEncoderWorker = ref(null)
|
|
const DOWNLOAD_CONCURRENCY = 3
|
|
const MERGE_DECODE_CONCURRENCY = 3
|
|
const ESTIMATE_DURATION_CONCURRENCY = 3
|
|
const DOWNLOAD_URL_RETRY_LIMIT = 3
|
|
const AUDIO_METADATA_TIMEOUT_MS = 10000
|
|
const NETEASE_PREVIEW_DURATION_SECONDS = 30
|
|
const PCM_BYTES_PER_SECOND = 44100 * 2 * 4
|
|
const FAST_MERGE_MIN_BUDGET_BYTES = 96 * 1024 * 1024
|
|
const FAST_MERGE_MEMORY_RATIO = 0.04
|
|
|
|
// 当前正在执行的任务类型 ('merge' | 'download' | '')
|
|
const currentTaskType = ref('')
|
|
|
|
// 预估时长状态
|
|
const estimatingDuration = ref(false)
|
|
const estimatedDurations = reactive(new Map())
|
|
|
|
const getPlatformShortName = (platform) => {
|
|
switch (platform) {
|
|
case 'netease':
|
|
return 'WY'
|
|
case 'netease-podcast':
|
|
return 'DJ'
|
|
case 'tencent':
|
|
return 'QQ'
|
|
case 'bilibili':
|
|
return 'BL'
|
|
default:
|
|
return 'OT'
|
|
}
|
|
}
|
|
|
|
const getSongPlatform = (song) => {
|
|
return (
|
|
song?.actualMusicPlatform ||
|
|
song?.musicPlatform ||
|
|
song?.sourceInfo?.actualMusicPlatform ||
|
|
song?.sourceInfo?.musicPlatform ||
|
|
''
|
|
)
|
|
}
|
|
|
|
const isNeteaseSong = (song) => {
|
|
const platform = getSongPlatform(song)
|
|
return platform === 'netease' || platform === 'netease-podcast'
|
|
}
|
|
|
|
const getNeteaseCookie = () => {
|
|
if (!import.meta.client) return ''
|
|
|
|
try {
|
|
return localStorage.getItem('netease_cookie') || ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
const getDownloadResolveOptions = (song, quality, excludeSources = [], ignoreProvidedUrl = false) => {
|
|
const platform = getSongPlatform(song)
|
|
const isPodcast =
|
|
platform === 'netease-podcast' ||
|
|
song?.sourceInfo?.type === 'voice' ||
|
|
(song?.sourceInfo?.source === 'netease-backup' && song?.sourceInfo?.type === 'voice')
|
|
|
|
return {
|
|
unblock: isPodcast ? false : undefined,
|
|
quality,
|
|
mediaId:
|
|
song?.sourceInfo?.strMediaMid ||
|
|
song?.sourceInfo?.mediaId ||
|
|
song?.sourceInfo?.mediaMid,
|
|
excludeSources,
|
|
ignoreProvidedUrl,
|
|
musicInfo: {
|
|
name: song?.title,
|
|
artist: song?.artist,
|
|
album: song?.album || undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
// VIP 标记由点歌表单校验写入;后台页未挂载点歌表单,有 Cookie 但缺标记时补一次校验
|
|
let qqVipCheckPromise = null
|
|
const ensureQqVipFlag = async () => {
|
|
if (!import.meta.client) return
|
|
const cookie = localStorage.getItem('qq_music_cookie')
|
|
if (!cookie || localStorage.getItem('qq_music_vip') !== null) return
|
|
|
|
if (!qqVipCheckPromise) {
|
|
qqVipCheckPromise = (async () => {
|
|
try {
|
|
const res = await $fetch('/api/native-api/qq/check-cookie', {
|
|
method: 'POST',
|
|
body: { cookie }
|
|
})
|
|
const data = res?.data || {}
|
|
if (data.valid && typeof data.isVip === 'boolean') {
|
|
localStorage.setItem('qq_music_vip', data.isVip ? '1' : '0')
|
|
}
|
|
} catch (e) {
|
|
console.warn('[SongDownloadDialog] QQ VIP 状态校验失败:', e)
|
|
} finally {
|
|
qqVipCheckPromise = null
|
|
}
|
|
})()
|
|
}
|
|
await qqVipCheckPromise
|
|
}
|
|
|
|
const isRetryableDownloadError = (error) => {
|
|
const message = String(error?.message || error || '')
|
|
return /404|not found|failed to fetch|networkerror|http( error|\s+\d+)|疑似试听时长|无法从播放链接获取时长|无法获取音乐播放链接/i.test(
|
|
message
|
|
)
|
|
}
|
|
|
|
const requestMusicUrlCandidate = async (song, quality, excludeSources, ignoreProvidedUrl) => {
|
|
const platform = getSongPlatform(song)
|
|
if (platform === 'tencent') {
|
|
await ensureQqVipFlag()
|
|
}
|
|
const result = await getMusicUrlResult(
|
|
platform,
|
|
song?.musicId,
|
|
song?.playUrl,
|
|
getDownloadResolveOptions(song, quality, excludeSources, ignoreProvidedUrl)
|
|
)
|
|
|
|
if (!result?.url) {
|
|
throw new Error(locale.value.musicUrlMissing)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// 音源解析串行化:批量下载时逐首解析链接,避免并发集中请求触发音源风控;音频拉流不受影响
|
|
let urlResolveQueue = Promise.resolve()
|
|
const resolveDownloadAudioCandidate = (
|
|
song,
|
|
quality,
|
|
excludeSources = [],
|
|
ignoreProvidedUrl = false
|
|
) => {
|
|
const task = urlResolveQueue.then(() =>
|
|
requestMusicUrlCandidate(song, quality, excludeSources, ignoreProvidedUrl)
|
|
)
|
|
// 链尾吞掉结果与错误,保证后续排队任务不被单个解析失败阻断
|
|
urlResolveQueue = task.then(
|
|
() => undefined,
|
|
() => undefined
|
|
)
|
|
return task
|
|
}
|
|
|
|
const withDownloadSourceFallback = async (song, quality, executor) => {
|
|
let excludeSources = []
|
|
const hasPlayUrl = !!(song?.playUrl && song.playUrl.trim())
|
|
let ignoreProvidedUrl = !hasPlayUrl
|
|
let lastError = null
|
|
|
|
for (let attempt = 0; attempt < DOWNLOAD_URL_RETRY_LIMIT; attempt++) {
|
|
let candidate
|
|
|
|
try {
|
|
candidate = await resolveDownloadAudioCandidate(
|
|
song,
|
|
quality,
|
|
excludeSources,
|
|
ignoreProvidedUrl
|
|
)
|
|
} catch (resolveError) {
|
|
lastError = resolveError
|
|
|
|
if (!isRetryableDownloadError(resolveError)) {
|
|
throw resolveError
|
|
}
|
|
|
|
ignoreProvidedUrl = true
|
|
continue
|
|
}
|
|
|
|
try {
|
|
return await executor(candidate)
|
|
} catch (error) {
|
|
lastError = error
|
|
|
|
if (!isRetryableDownloadError(error)) {
|
|
throw error
|
|
}
|
|
|
|
if (candidate.source === 'play-url') {
|
|
ignoreProvidedUrl = true
|
|
continue
|
|
}
|
|
|
|
if (candidate.source && !excludeSources.includes(candidate.source)) {
|
|
excludeSources = [...excludeSources, candidate.source]
|
|
continue
|
|
}
|
|
|
|
if (ignoreProvidedUrl) {
|
|
throw error
|
|
}
|
|
|
|
ignoreProvidedUrl = true
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error(locale.value.musicUrlMissing)
|
|
}
|
|
|
|
// 格式化时长
|
|
const formatDuration = (seconds) => {
|
|
if (!seconds) return '00:00'
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
const readAudioMetadataDuration = (src, options = {}) => {
|
|
if (!src) return Promise.resolve(0)
|
|
|
|
const timeoutMs = options.timeoutMs || AUDIO_METADATA_TIMEOUT_MS
|
|
const revokeObjectUrl = options.revokeObjectUrl || false
|
|
|
|
return new Promise((resolve) => {
|
|
const audio = new Audio()
|
|
let settled = false
|
|
let timeoutId = null
|
|
|
|
const cleanup = () => {
|
|
audio.onloadedmetadata = null
|
|
audio.onerror = null
|
|
audio.src = ''
|
|
audio.load()
|
|
if (revokeObjectUrl) {
|
|
URL.revokeObjectURL(src)
|
|
}
|
|
}
|
|
|
|
const finish = (duration = 0) => {
|
|
if (settled) return
|
|
settled = true
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
cleanup()
|
|
resolve(duration)
|
|
}
|
|
|
|
timeoutId = setTimeout(() => finish(0), timeoutMs)
|
|
audio.preload = 'metadata'
|
|
audio.onloadedmetadata = () => finish(audio.duration)
|
|
audio.onerror = () => finish(0)
|
|
audio.src = src
|
|
})
|
|
}
|
|
|
|
const getUsablePreload = (songId, quality) => {
|
|
const cached = preloadedSongs.get(songId)
|
|
if (!cached || cached.loading || !cached.blob) return null
|
|
if (cached.quality !== undefined && cached.quality !== quality) return null
|
|
return cached
|
|
}
|
|
|
|
// 预下载单首歌曲
|
|
const preloadSong = async (song) => {
|
|
if (getUsablePreload(song.id, selectedQuality.value)) return
|
|
|
|
preloadedSongs.set(song.id, { loading: true, progress: 0 })
|
|
|
|
try {
|
|
const result = await withDownloadSourceFallback(song, selectedQuality.value, async (candidate) => {
|
|
const response = await fetch(candidate.url)
|
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
|
|
const contentLength = response.headers.get('content-length')
|
|
const total = contentLength ? parseInt(contentLength, 10) : 0
|
|
let loaded = 0
|
|
|
|
const reader = response.body.getReader()
|
|
const chunks = []
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
chunks.push(value)
|
|
loaded += value.length
|
|
|
|
if (total) {
|
|
const progress = (loaded / total) * 100
|
|
const current = preloadedSongs.get(song.id)
|
|
if (current) {
|
|
current.progress = progress
|
|
}
|
|
}
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type') || 'audio/mpeg'
|
|
const blob = new Blob(chunks, { type: contentType })
|
|
const objectUrl = URL.createObjectURL(blob)
|
|
const duration = await readAudioMetadataDuration(objectUrl, { revokeObjectUrl: true })
|
|
|
|
return {
|
|
blob,
|
|
duration,
|
|
url: candidate.url,
|
|
contentType
|
|
}
|
|
})
|
|
|
|
preloadedSongs.set(song.id, {
|
|
blob: result.blob,
|
|
duration: result.duration,
|
|
loading: false,
|
|
progress: 100,
|
|
quality: selectedQuality.value,
|
|
url: result.url,
|
|
contentType: result.contentType
|
|
})
|
|
} catch (error) {
|
|
console.error('预下载失败:', error)
|
|
preloadedSongs.delete(song.id)
|
|
if (window.$showNotification) {
|
|
window.$showNotification(getLocaleText('preloadFailed', '', song.title), 'error')
|
|
}
|
|
}
|
|
}
|
|
|
|
// 批量预下载
|
|
const preloadSelectedSongs = async () => {
|
|
if (selectedSongs.value.size === 0) return
|
|
|
|
const songsToLoad = props.songs.filter(
|
|
(s) => {
|
|
const songId = s.song?.id
|
|
if (!songId || !selectedSongs.value.has(songId)) return false
|
|
const cached = preloadedSongs.get(songId)
|
|
if (cached && cached.loading) return false
|
|
return !getUsablePreload(songId, selectedQuality.value)
|
|
}
|
|
)
|
|
|
|
// 并发限制: 3
|
|
const concurrency = 3
|
|
const queue = [...songsToLoad]
|
|
const workers = []
|
|
|
|
const worker = async () => {
|
|
while (queue.length > 0) {
|
|
const songItem = queue.shift()
|
|
await preloadSong(songItem.song)
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < Math.min(concurrency, songsToLoad.length); i++) {
|
|
workers.push(worker())
|
|
}
|
|
|
|
await Promise.all(workers)
|
|
}
|
|
|
|
const removePreloaded = (songId) => {
|
|
preloadedSongs.delete(songId)
|
|
}
|
|
|
|
// 预估选中歌曲的时长
|
|
const estimateSelectedDurations = async () => {
|
|
if (selectedSongs.value.size === 0 || estimatingDuration.value) return
|
|
|
|
estimatingDuration.value = true
|
|
|
|
try {
|
|
const level = neteaseQualityLevelMap[selectedQuality.value] || 'exhigh'
|
|
const neteaseCookie = getNeteaseCookie()
|
|
|
|
const songsToEstimate = []
|
|
|
|
// 筛选需要预估的歌曲
|
|
props.songs.forEach((songItem) => {
|
|
const songId = songItem.song?.id
|
|
if (!songId || !selectedSongs.value.has(songId)) return
|
|
|
|
// 已有数据库时长、缓存、API 预估时,无需再次请求网络。
|
|
if (getKnownSongDuration(songItem) > 0) return
|
|
|
|
songsToEstimate.push(songItem)
|
|
})
|
|
|
|
console.log(`[预估时长] 需要预估的歌曲数量: ${songsToEstimate.length}`)
|
|
|
|
if (songsToEstimate.length === 0) {
|
|
if (window.$showNotification) {
|
|
window.$showNotification(locale.value.allSelectedHaveDuration, 'info')
|
|
}
|
|
estimatingDuration.value = false
|
|
return
|
|
}
|
|
|
|
let successCount = 0
|
|
let failCount = 0
|
|
|
|
const estimateSongDuration = async (songItem) => {
|
|
try {
|
|
console.log('[预估时长] 正在获取歌曲时长:', songItem.song.title)
|
|
|
|
if (isNeteaseSong(songItem.song)) {
|
|
try {
|
|
const apiResponse = await $fetch('/api/api-enhanced/netease/song/url/v1', {
|
|
params: {
|
|
id: songItem.song.musicId,
|
|
level: level,
|
|
cookie: neteaseCookie || undefined,
|
|
unblock: neteaseCookie ? false : true
|
|
},
|
|
timeout: 5000
|
|
})
|
|
|
|
if (apiResponse?.code === 200 && Array.isArray(apiResponse.data) && apiResponse.data.length > 0) {
|
|
const songData = apiResponse.data[0]
|
|
const durationMs = songData.time
|
|
|
|
if (durationMs && durationMs > 0) {
|
|
const durationSeconds = Math.floor(durationMs / 1000)
|
|
if (durationSeconds !== NETEASE_PREVIEW_DURATION_SECONDS) {
|
|
estimatedDurations.set(songItem.song.id, {
|
|
durationSeconds,
|
|
durationMs: durationMs,
|
|
source: 'netease-api'
|
|
})
|
|
console.log('[预估时长] 成功获取时长:', songItem.song.title, durationSeconds + '秒')
|
|
successCount++
|
|
return
|
|
}
|
|
console.warn('[预估时长] 网易API返回疑似试听时长,尝试第三方音源:', songItem.song.title)
|
|
}
|
|
}
|
|
} catch (apiError) {
|
|
console.warn('[预估时长] 网易API调用失败,尝试从播放链接获取:', songItem.song.title, apiError)
|
|
}
|
|
}
|
|
|
|
// 非网易音源、网易 API 失败或返回试听片段时,复用预下载的音源解析逻辑读取元数据。
|
|
try {
|
|
const durationResult = await withDownloadSourceFallback(
|
|
songItem.song,
|
|
selectedQuality.value,
|
|
async (candidate) => {
|
|
const duration = await readAudioMetadataDuration(candidate.url)
|
|
|
|
if (!duration || duration <= 0) {
|
|
throw new Error(locale.value.durationFromPlaybackUrlFailed)
|
|
}
|
|
|
|
const durationSeconds = Math.floor(duration)
|
|
if (
|
|
isNeteaseSong(songItem.song) &&
|
|
durationSeconds === NETEASE_PREVIEW_DURATION_SECONDS
|
|
) {
|
|
throw new Error(locale.value.previewDurationDetected)
|
|
}
|
|
|
|
return {
|
|
durationSeconds,
|
|
durationMs: Math.floor(duration * 1000),
|
|
source: 'audio-metadata'
|
|
}
|
|
}
|
|
)
|
|
|
|
estimatedDurations.set(songItem.song.id, durationResult)
|
|
console.log(
|
|
'[预估时长] 从播放链接获取时长:',
|
|
songItem.song.title,
|
|
durationResult.durationSeconds + '秒'
|
|
)
|
|
successCount++
|
|
} catch (audioError) {
|
|
console.error('[预估时长] 播放链接获取时长失败:', songItem.song.title, audioError)
|
|
failCount++
|
|
}
|
|
} catch (error) {
|
|
console.error('[预估时长] 处理歌曲失败:', songItem.song.title, error)
|
|
failCount++
|
|
}
|
|
}
|
|
|
|
const queue = [...songsToEstimate]
|
|
const workers = []
|
|
const worker = async () => {
|
|
while (queue.length > 0) {
|
|
const songItem = queue.shift()
|
|
if (songItem) {
|
|
await estimateSongDuration(songItem)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < Math.min(ESTIMATE_DURATION_CONCURRENCY, songsToEstimate.length); i++) {
|
|
workers.push(worker())
|
|
}
|
|
|
|
await Promise.all(workers)
|
|
|
|
console.log(`[预估时长] 完成 - 成功: ${successCount}, 失败: ${failCount}`)
|
|
|
|
if (successCount > 0 && window.$showNotification) {
|
|
window.$showNotification(getLocaleText('estimateSuccess', '', successCount), 'success')
|
|
}
|
|
|
|
if (failCount > 0 && window.$showNotification) {
|
|
window.$showNotification(getLocaleText('estimateFailedCount', '', failCount), 'warning')
|
|
}
|
|
} catch (error) {
|
|
console.error('[预估时长] 总体失败:', error)
|
|
if (window.$showNotification) {
|
|
window.$showNotification(locale.value.estimateFailed, 'error')
|
|
}
|
|
} finally {
|
|
estimatingDuration.value = false
|
|
}
|
|
}
|
|
|
|
// 计算预估总时长
|
|
const estimatedTotalDuration = computed(() => {
|
|
let total = 0
|
|
let count = 0
|
|
const songMap = new Map(props.songs.map((item) => [item.song?.id, item]))
|
|
selectedSongs.value.forEach((id) => {
|
|
const songItem = songMap.get(id)
|
|
if (!songItem) return
|
|
|
|
const duration = getKnownSongDuration(songItem)
|
|
if (duration > 0) {
|
|
total += duration
|
|
count++
|
|
}
|
|
})
|
|
return { total, count }
|
|
})
|
|
|
|
const getKnownSongDuration = (songItem) => {
|
|
const song = songItem.song
|
|
// 优先使用数据库记录的时长
|
|
if (typeof song.durationSeconds === 'number' && song.durationSeconds > 0) {
|
|
return song.durationSeconds
|
|
}
|
|
|
|
// 其次使用预下载的精确时长
|
|
const cached = getUsablePreload(song.id, selectedQuality.value)
|
|
if (cached?.duration) return cached.duration
|
|
|
|
// 最后使用 API 预估时长
|
|
const estimated = estimatedDurations.get(song.id)
|
|
if (estimated?.durationSeconds) return estimated.durationSeconds
|
|
|
|
return 0
|
|
}
|
|
|
|
const getBrowserMergeMemoryBudget = () => {
|
|
const deviceMemoryGb = Number(navigator.deviceMemory)
|
|
if (Number.isFinite(deviceMemoryGb) && deviceMemoryGb > 0) {
|
|
return Math.max(
|
|
FAST_MERGE_MIN_BUDGET_BYTES,
|
|
deviceMemoryGb * 1024 * 1024 * 1024 * FAST_MERGE_MEMORY_RATIO
|
|
)
|
|
}
|
|
|
|
const heapLimit = performance?.memory?.jsHeapSizeLimit
|
|
if (Number.isFinite(heapLimit) && heapLimit > 0) {
|
|
return Math.max(FAST_MERGE_MIN_BUDGET_BYTES, heapLimit * 0.12)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const shouldUseFastMergeMode = (selectedSongsList) => {
|
|
const deviceMemoryGb = Number(navigator.deviceMemory)
|
|
let knownDurationCount = 0
|
|
let totalDuration = 0
|
|
|
|
for (const songItem of selectedSongsList) {
|
|
const duration = getKnownSongDuration(songItem)
|
|
if (duration > 0) {
|
|
knownDurationCount++
|
|
totalDuration += duration
|
|
}
|
|
}
|
|
|
|
const estimatedPcmBytes = totalDuration * PCM_BYTES_PER_SECOND
|
|
const memoryBudget = getBrowserMergeMemoryBudget()
|
|
|
|
if (memoryBudget && estimatedPcmBytes > memoryBudget) {
|
|
return false
|
|
}
|
|
|
|
if (knownDurationCount === selectedSongsList.length && memoryBudget) {
|
|
return estimatedPcmBytes <= memoryBudget
|
|
}
|
|
|
|
if (Number.isFinite(deviceMemoryGb)) {
|
|
if (deviceMemoryGb <= 4) return selectedSongsList.length <= 2 && estimatedPcmBytes <= 64 * 1024 * 1024
|
|
if (deviceMemoryGb >= 8) return selectedSongsList.length <= 10
|
|
}
|
|
|
|
return selectedSongsList.length <= 3 && estimatedPcmBytes <= FAST_MERGE_MIN_BUDGET_BYTES
|
|
}
|
|
|
|
const isLikelyMemoryError = (error) => {
|
|
const message = String(error?.message || error || '')
|
|
return /memory|allocation|array buffer|out of memory|内存/i.test(message)
|
|
}
|
|
|
|
const pushMergeError = (error) => {
|
|
downloadErrors.value.push({
|
|
id: 'merge_error',
|
|
title: locale.value.mergeTitle,
|
|
artist: '',
|
|
error: error?.message || String(error || locale.value.mergeFailed)
|
|
})
|
|
}
|
|
|
|
const toggleSelectAll = () => {
|
|
if (isAllSelected.value) {
|
|
selectedSongs.value = new Set()
|
|
} else {
|
|
selectedSongs.value = new Set(props.songs.map((song) => song.song.id))
|
|
}
|
|
}
|
|
|
|
const toggleSongSelection = (songId) => {
|
|
if (selectedSongs.value.has(songId)) {
|
|
selectedSongs.value.delete(songId)
|
|
} else {
|
|
selectedSongs.value.add(songId)
|
|
}
|
|
}
|
|
|
|
const closeDialog = () => {
|
|
// 下载中关闭仅隐藏弹窗,后台继续运行
|
|
emit('close')
|
|
}
|
|
|
|
// 通用的音频下载函数
|
|
const fetchAudioWithProgress = async (audioUrl, songId, songTitle) => {
|
|
const response = await fetch(audioUrl)
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
|
|
const total = parseInt(response.headers.get('content-length') || '0')
|
|
const reader = response.body.getReader()
|
|
const chunks = []
|
|
let loaded = 0
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
chunks.push(value)
|
|
loaded += value.length
|
|
|
|
if (total) {
|
|
const percent = Math.round((loaded / total) * 100)
|
|
processingStatus.value = getLocaleText('downloadingProgress', '', songTitle, percent)
|
|
activeDownloads.set(songId, percent)
|
|
}
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type') || 'audio/mpeg'
|
|
return new Blob(chunks, { type: contentType })
|
|
}
|
|
|
|
const getAudioBlobForSong = async (song, quality) => {
|
|
const cached = getUsablePreload(song.id, quality)
|
|
if (cached) {
|
|
processingStatus.value = getLocaleText('usingPreloadCache', '', song.title)
|
|
activeDownloads.set(song.id, 100)
|
|
return {
|
|
blob: cached.blob,
|
|
sourceUrl: cached.url || '',
|
|
fromCache: true
|
|
}
|
|
}
|
|
|
|
return await withDownloadSourceFallback(song, quality, async (candidate) => {
|
|
processingStatus.value = getLocaleText('downloadingTitle', '', song.title)
|
|
const blob = await fetchAudioWithProgress(candidate.url, song.id, song.title)
|
|
return {
|
|
blob,
|
|
sourceUrl: candidate.url,
|
|
fromCache: false
|
|
}
|
|
})
|
|
}
|
|
|
|
// 触发浏览器下载
|
|
const saveFile = (blob, filename) => {
|
|
const objectUrl = window.URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = objectUrl
|
|
link.download = filename
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
window.URL.revokeObjectURL(objectUrl)
|
|
}
|
|
|
|
const terminateActiveEncoderWorker = () => {
|
|
if (activeEncoderWorker.value) {
|
|
activeEncoderWorker.value.terminate()
|
|
activeEncoderWorker.value = null
|
|
}
|
|
}
|
|
|
|
const createDownloadAudioContext = () => {
|
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext
|
|
try {
|
|
return new AudioContextClass({ sampleRate: 44100 })
|
|
} catch {
|
|
return new AudioContextClass()
|
|
}
|
|
}
|
|
|
|
const buildMergedFilename = (selectedSongsList, ext, customFilenameValue) => {
|
|
let filename
|
|
if (customFilenameValue && customFilenameValue.trim()) {
|
|
filename = customFilenameValue.trim()
|
|
const dateStr = new Date().toLocaleDateString('sv-SE')
|
|
const allSongsStr = selectedSongsList.map((item) => item.song.title).join(' ')
|
|
filename = filename.replace(/{date}/g, dateStr)
|
|
filename = filename.replace(/{songs}/g, allSongsStr)
|
|
filename = filename.replace(/[<>:"/\\|?*]/g, '_')
|
|
const extRegex = new RegExp(`\\.${ext}$`, 'i')
|
|
if (!extRegex.test(filename)) {
|
|
const knownExtensions = /\.(mp3|wav|flac|m4a|ogg|aac|wma|alac|aiff|ape)$/i
|
|
if (knownExtensions.test(filename)) {
|
|
filename = filename.replace(knownExtensions, `.${ext}`)
|
|
} else {
|
|
filename += `.${ext}`
|
|
}
|
|
}
|
|
} else {
|
|
const dateStr = new Date().toLocaleDateString('sv-SE')
|
|
filename = getLocaleText('mergedFilename', `排期合并_${dateStr}_${selectedSongsList.length}首.${ext}`, dateStr, selectedSongsList.length, ext)
|
|
}
|
|
return filename
|
|
}
|
|
|
|
const formatWorkerProgress = (stage, value, format) => {
|
|
if (stage === 'prepare') {
|
|
processingStatus.value = getLocaleText('preprocessingAudio', '', value)
|
|
return
|
|
}
|
|
if (stage === 'merge') {
|
|
processingStatus.value = getLocaleText('mergingAudio', '', value)
|
|
return
|
|
}
|
|
processingStatus.value = getLocaleText('encodingAudio', '', format.toUpperCase(), value)
|
|
}
|
|
|
|
const getTrackTransferables = (track) => {
|
|
return track.left.buffer === track.right.buffer
|
|
? [track.left.buffer]
|
|
: [track.left.buffer, track.right.buffer]
|
|
}
|
|
|
|
const encodeWithWorker = async (tracks, format, config) => {
|
|
terminateActiveEncoderWorker()
|
|
const worker = new Worker(new URL('../../workers/audioEncoderWorker.js', import.meta.url), {
|
|
type: 'module'
|
|
})
|
|
activeEncoderWorker.value = worker
|
|
return await new Promise((resolve, reject) => {
|
|
worker.onmessage = (event) => {
|
|
const { type, stage, value, blob, message } = event.data || {}
|
|
if (type === 'progress') {
|
|
formatWorkerProgress(stage, value, format)
|
|
return
|
|
}
|
|
if (type === 'error') {
|
|
terminateActiveEncoderWorker()
|
|
reject(new Error(message || locale.value.encodingFailed))
|
|
return
|
|
}
|
|
if (type === 'done') {
|
|
terminateActiveEncoderWorker()
|
|
resolve(blob)
|
|
}
|
|
}
|
|
worker.onerror = (event) => {
|
|
terminateActiveEncoderWorker()
|
|
reject(new Error(event.message || locale.value.encoderWorkerFailed))
|
|
}
|
|
const transferables = []
|
|
const payloadTracks = tracks.map((track) => {
|
|
transferables.push(...getTrackTransferables(track))
|
|
return {
|
|
id: track.id,
|
|
title: track.title,
|
|
sampleRate: track.sampleRate,
|
|
left: track.left.buffer,
|
|
right: track.right.buffer
|
|
}
|
|
})
|
|
worker.postMessage(
|
|
{
|
|
cmd: 'encode',
|
|
tracks: payloadTracks,
|
|
format,
|
|
normalize: config.normalizeAudio,
|
|
targetDb: config.targetDb
|
|
},
|
|
transferables
|
|
)
|
|
})
|
|
}
|
|
|
|
const createStreamingEncoderSession = async (format, config, sampleRate) => {
|
|
terminateActiveEncoderWorker()
|
|
const worker = new Worker(new URL('../../workers/audioEncoderWorker.js', import.meta.url), {
|
|
type: 'module'
|
|
})
|
|
activeEncoderWorker.value = worker
|
|
let requestId = 0
|
|
const pendingRequests = new Map()
|
|
let isAborted = false
|
|
|
|
const terminateSessionWorker = () => {
|
|
worker.terminate()
|
|
if (activeEncoderWorker.value === worker) {
|
|
activeEncoderWorker.value = null
|
|
}
|
|
}
|
|
|
|
const rejectPendingRequests = (error) => {
|
|
pendingRequests.forEach(({ reject }) => reject(error))
|
|
pendingRequests.clear()
|
|
}
|
|
|
|
const abortSession = (error) => {
|
|
if (isAborted) return
|
|
isAborted = true
|
|
rejectPendingRequests(error)
|
|
terminateSessionWorker()
|
|
}
|
|
|
|
worker.onmessage = (event) => {
|
|
const { type, stage, value, blob, message, requestId: responseRequestId } = event.data || {}
|
|
if (type === 'progress') {
|
|
formatWorkerProgress(stage, value, format)
|
|
return
|
|
}
|
|
|
|
if (type === 'error') {
|
|
const error = new Error(message || locale.value.encodingFailed)
|
|
const pending = pendingRequests.get(responseRequestId)
|
|
if (pending) {
|
|
pendingRequests.delete(responseRequestId)
|
|
pending.reject(error)
|
|
}
|
|
abortSession(error)
|
|
return
|
|
}
|
|
|
|
const pending = pendingRequests.get(responseRequestId)
|
|
if (!pending) return
|
|
|
|
if (pending.expectedTypes.includes(type)) {
|
|
pendingRequests.delete(responseRequestId)
|
|
pending.resolve({ type, blob })
|
|
}
|
|
}
|
|
|
|
worker.onerror = (event) => {
|
|
const error = new Error(event.message || locale.value.encoderWorkerFailed)
|
|
abortSession(error)
|
|
}
|
|
|
|
const sendCommand = (payload, transferables = [], expectedTypes = []) => {
|
|
if (isAborted) {
|
|
return Promise.reject(new Error(locale.value.encoderWorkerTerminated))
|
|
}
|
|
|
|
const nextRequestId = ++requestId
|
|
return new Promise((resolve, reject) => {
|
|
pendingRequests.set(nextRequestId, { resolve, reject, expectedTypes })
|
|
try {
|
|
worker.postMessage({ ...payload, requestId: nextRequestId }, transferables)
|
|
} catch (error) {
|
|
pendingRequests.delete(nextRequestId)
|
|
isAborted = true
|
|
reject(error)
|
|
rejectPendingRequests(error)
|
|
terminateSessionWorker()
|
|
}
|
|
})
|
|
}
|
|
|
|
await sendCommand(
|
|
{
|
|
cmd: 'startStream',
|
|
format,
|
|
normalize: config.normalizeAudio,
|
|
targetDb: config.targetDb,
|
|
sampleRate
|
|
},
|
|
[],
|
|
['ready']
|
|
)
|
|
|
|
return {
|
|
appendTrack: async (track) => {
|
|
await sendCommand(
|
|
{
|
|
cmd: 'appendTrack',
|
|
track: {
|
|
id: track.id,
|
|
title: track.title,
|
|
sampleRate: track.sampleRate,
|
|
left: track.left.buffer,
|
|
right: track.right.buffer
|
|
}
|
|
},
|
|
getTrackTransferables(track),
|
|
['trackDone']
|
|
)
|
|
},
|
|
finish: async () => {
|
|
const result = await sendCommand({ cmd: 'finishStream' }, [], ['done'])
|
|
terminateSessionWorker()
|
|
return result.blob
|
|
},
|
|
cancel: () => {
|
|
if (isAborted) return
|
|
isAborted = true
|
|
rejectPendingRequests(new Error(locale.value.encodingCanceled))
|
|
terminateSessionWorker()
|
|
}
|
|
}
|
|
}
|
|
|
|
const decodeAudioBlobToTrack = async (song, blob, audioContext) => {
|
|
const arrayBuffer = await blob.arrayBuffer()
|
|
processingStatus.value = getLocaleText('decoding', '', song.title)
|
|
const decoded = await audioContext.decodeAudioData(arrayBuffer)
|
|
const left = new Float32Array(decoded.getChannelData(0))
|
|
const right =
|
|
decoded.numberOfChannels > 1
|
|
? new Float32Array(decoded.getChannelData(1))
|
|
: left
|
|
return {
|
|
id: song.id,
|
|
title: song.title,
|
|
sampleRate: decoded.sampleRate,
|
|
left,
|
|
right
|
|
}
|
|
}
|
|
|
|
const decodeSongTrack = async (song, audioContext, quality, options = {}) => {
|
|
const { blob, fromCache } = await getAudioBlobForSong(song, quality)
|
|
const track = await decodeAudioBlobToTrack(song, blob, audioContext)
|
|
if (fromCache && options.releasePreload) {
|
|
preloadedSongs.delete(song.id)
|
|
}
|
|
activeDownloads.set(song.id, 100)
|
|
return track
|
|
}
|
|
|
|
const inferAudioExtension = (blob, sourceUrl) => {
|
|
const contentType = blob.type || ''
|
|
const url = sourceUrl || ''
|
|
if (contentType.includes('m4a') || url.includes('.m4a')) return 'm4a'
|
|
if (contentType.includes('mp4') || url.includes('.m4s')) return 'm4a'
|
|
if (contentType.includes('flac') || url.includes('.flac')) return 'flac'
|
|
if (contentType.includes('wav') || url.includes('.wav')) return 'wav'
|
|
if (contentType.includes('ogg') || url.includes('.ogg')) return 'ogg'
|
|
return 'mp3'
|
|
}
|
|
|
|
const buildSingleFilename = (song, extension) => {
|
|
return `${song.artist} - ${song.title}.${extension}`.replace(/[<>:"/\\|?*]/g, '_')
|
|
}
|
|
|
|
const encodeSingleSong = async (song, audioContext, config) => {
|
|
const track = await decodeSongTrack(song, audioContext, config.quality)
|
|
const extension = config.exportFormat === 'wav' ? 'wav' : 'mp3'
|
|
processingStatus.value = config.normalizeAudio
|
|
? getLocaleText('normalizing', '', song.title)
|
|
: getLocaleText('converting', '', song.title)
|
|
const blob = await encodeWithWorker([track], extension, config)
|
|
return { blob, extension }
|
|
}
|
|
|
|
const processAndMergeAudioFast = async (selectedSongsList, config) => {
|
|
const audioContext = createDownloadAudioContext()
|
|
|
|
try {
|
|
const concurrency = MERGE_DECODE_CONCURRENCY
|
|
const queue = selectedSongsList.map((item, index) => ({ item, index }))
|
|
const results = new Array(selectedSongsList.length).fill(null)
|
|
const workers = []
|
|
|
|
const worker = async () => {
|
|
while (queue.length > 0) {
|
|
const { item: songItem, index } = queue.shift()
|
|
const song = songItem.song
|
|
|
|
currentDownloadSong.value = `${song.artist} - ${song.title}`
|
|
processingStatus.value = getLocaleText('fastPreparing', '', song.title)
|
|
|
|
try {
|
|
results[index] = await decodeSongTrack(song, audioContext, config.quality)
|
|
} catch (error) {
|
|
console.error(`处理失败: ${song.title}`, error)
|
|
downloadErrors.value.push({
|
|
id: song.id,
|
|
title: song.title,
|
|
artist: song.artist,
|
|
error: error.message
|
|
})
|
|
} finally {
|
|
downloadedCount.value++
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < Math.min(concurrency, selectedSongsList.length); i++) {
|
|
workers.push(worker())
|
|
}
|
|
|
|
await Promise.all(workers)
|
|
|
|
const tracks = results.filter(Boolean)
|
|
|
|
if (tracks.length === 0) throw new Error(locale.value.noProcessedAudio)
|
|
|
|
processingStatus.value = locale.value.fastMerging
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
|
|
const extension = config.exportFormat === 'wav' ? 'wav' : 'mp3'
|
|
const resultBlob = await encodeWithWorker(tracks, extension, config)
|
|
downloadedCount.value = totalCount.value
|
|
const filename = buildMergedFilename(selectedSongsList, extension, config.customFilename)
|
|
|
|
saveFile(resultBlob, filename)
|
|
processingStatus.value = getLocaleText('completedFile', '', filename)
|
|
currentDownloadSong.value = ''
|
|
} finally {
|
|
terminateActiveEncoderWorker()
|
|
audioContext.close()
|
|
processingStatus.value = ''
|
|
}
|
|
}
|
|
|
|
const processAndMergeAudioStreaming = async (selectedSongsList, config) => {
|
|
const audioContext = createDownloadAudioContext()
|
|
const extension = config.exportFormat === 'wav' ? 'wav' : 'mp3'
|
|
let streamSession = null
|
|
|
|
try {
|
|
streamSession = await createStreamingEncoderSession(
|
|
extension,
|
|
config,
|
|
audioContext.sampleRate || 44100
|
|
)
|
|
let successCount = 0
|
|
|
|
for (let i = 0; i < selectedSongsList.length; i++) {
|
|
const song = selectedSongsList[i].song
|
|
currentDownloadSong.value = `${song.artist} - ${song.title}`
|
|
processingStatus.value = getLocaleText('preparingSong', '', song.title)
|
|
|
|
let track = null
|
|
try {
|
|
// 老设备内存更容易被多首 PCM 同时占满,因此合并时始终逐首解码和编码。
|
|
track = await decodeSongTrack(song, audioContext, config.quality, {
|
|
releasePreload: true
|
|
})
|
|
} catch (error) {
|
|
console.error(`解码失败: ${song.title}`, error)
|
|
downloadErrors.value.push({
|
|
id: song.id,
|
|
title: song.title,
|
|
artist: song.artist,
|
|
error: getLocaleText('decodeFailed', error.message, error.message)
|
|
})
|
|
activeDownloads.delete(song.id)
|
|
downloadedCount.value++
|
|
continue
|
|
}
|
|
|
|
try {
|
|
processingStatus.value = getLocaleText('writingMergedFile', '', song.title)
|
|
await streamSession.appendTrack(track)
|
|
successCount++
|
|
} catch (error) {
|
|
console.error(`写入合并文件失败: ${song.title}`, error)
|
|
const errorText = getErrorMessage(error)
|
|
throw new Error(getLocaleText('writeMergedFileFailed', errorText, errorText))
|
|
} finally {
|
|
activeDownloads.delete(song.id)
|
|
downloadedCount.value++
|
|
}
|
|
}
|
|
|
|
if (successCount === 0) throw new Error(locale.value.noProcessedAudio)
|
|
|
|
processingStatus.value = locale.value.finishingMergedFile
|
|
const resultBlob = await streamSession.finish()
|
|
streamSession = null
|
|
downloadedCount.value = totalCount.value
|
|
const filename = buildMergedFilename(selectedSongsList, extension, config.customFilename)
|
|
|
|
saveFile(resultBlob, filename)
|
|
processingStatus.value = getLocaleText('completedFile', '', filename)
|
|
currentDownloadSong.value = ''
|
|
} catch (error) {
|
|
console.error('合并过程出错:', error)
|
|
pushMergeError(error)
|
|
if (window.$showNotification) {
|
|
window.$showNotification(`${getLocaleText('mergeFailed')}: ${getErrorMessage(error)}`, 'error')
|
|
}
|
|
} finally {
|
|
if (streamSession) {
|
|
streamSession.cancel()
|
|
}
|
|
terminateActiveEncoderWorker()
|
|
audioContext.close()
|
|
processingStatus.value = ''
|
|
}
|
|
}
|
|
|
|
const processAndMergeAudio = async (selectedSongsList, config) => {
|
|
const useFastMergeMode = shouldUseFastMergeMode(selectedSongsList)
|
|
|
|
if (!useFastMergeMode) {
|
|
processingStatus.value = locale.value.compatibleMerge
|
|
await processAndMergeAudioStreaming(selectedSongsList, config)
|
|
return
|
|
}
|
|
|
|
try {
|
|
processingStatus.value = locale.value.fastMerge
|
|
await processAndMergeAudioFast(selectedSongsList, config)
|
|
} catch (error) {
|
|
if (isLikelyMemoryError(error)) {
|
|
console.warn('快速合并内存不足,切换到兼容模式:', error)
|
|
downloadedCount.value = 0
|
|
downloadErrors.value = []
|
|
activeDownloads.clear()
|
|
if (window.$showNotification) {
|
|
window.$showNotification(locale.value.fastMergeMemoryFallback, 'warning')
|
|
}
|
|
await processAndMergeAudioStreaming(selectedSongsList, config)
|
|
return
|
|
}
|
|
|
|
console.error('合并过程出错:', error)
|
|
pushMergeError(error)
|
|
if (window.$showNotification) {
|
|
window.$showNotification(`${getLocaleText('mergeFailed')}: ${getErrorMessage(error)}`, 'error')
|
|
}
|
|
}
|
|
}
|
|
|
|
// 开始下载任务
|
|
const startDownload = async () => {
|
|
if (selectedSongs.value.size === 0) return
|
|
|
|
// 保存音质选择偏好
|
|
localStorage.setItem('voicehub_quality_preset', selectedQuality.value)
|
|
|
|
downloading.value = true
|
|
downloadedCount.value = 0
|
|
downloadErrors.value = []
|
|
processingStatus.value = ''
|
|
currentTaskType.value = shouldMergeSongs.value ? 'merge' : 'download'
|
|
|
|
// 快照当前配置,防止下载过程中用户修改配置影响当前任务
|
|
const taskConfig = {
|
|
quality: selectedQuality.value,
|
|
exportFormat: exportFormat.value,
|
|
customFilename: customFilename.value,
|
|
convertAudioFormat: convertAudioFormat.value,
|
|
normalizeAudio: normalizeAudio.value,
|
|
targetDb: targetDb.value
|
|
}
|
|
|
|
const selectedSongsList = props.songs.filter((song) => selectedSongs.value.has(song.song.id))
|
|
// 合并模式分支
|
|
if (currentTaskType.value === 'merge') {
|
|
totalCount.value = selectedSongsList.length + 1
|
|
await processAndMergeAudio(selectedSongsList, taskConfig)
|
|
|
|
// 合并完成处理
|
|
if (downloadErrors.value.length === 0) {
|
|
// 清理缓存
|
|
preloadedSongs.clear()
|
|
|
|
setTimeout(() => {
|
|
downloading.value = false
|
|
currentTaskType.value = ''
|
|
closeDialog()
|
|
}, 2000)
|
|
} else {
|
|
downloading.value = false
|
|
currentTaskType.value = ''
|
|
}
|
|
return
|
|
}
|
|
totalCount.value = selectedSongsList.length
|
|
|
|
const shouldEncodeDownloads = taskConfig.convertAudioFormat || taskConfig.normalizeAudio
|
|
// 普通批量下载模式。重编码会占用单个编码 Worker,逐首处理更稳。
|
|
const concurrency = shouldEncodeDownloads ? 1 : DOWNLOAD_CONCURRENCY
|
|
const queue = [...selectedSongsList]
|
|
const workers = []
|
|
let activeWorkers = 0
|
|
const audioContext = shouldEncodeDownloads ? createDownloadAudioContext() : null
|
|
|
|
const worker = async () => {
|
|
while (queue.length > 0) {
|
|
const songItem = queue.shift()
|
|
if (!songItem) break
|
|
|
|
const song = songItem.song
|
|
activeWorkers++
|
|
currentDownloadSong.value = getLocaleText('activeWorkers', '', `${song.artist} - ${song.title}`, activeWorkers, concurrency)
|
|
|
|
try {
|
|
let blob
|
|
let extension
|
|
|
|
if (shouldEncodeDownloads) {
|
|
const result = await encodeSingleSong(song, audioContext, taskConfig)
|
|
blob = result.blob
|
|
extension = result.extension
|
|
} else {
|
|
const result = await getAudioBlobForSong(song, taskConfig.quality)
|
|
blob = result.blob
|
|
extension = inferAudioExtension(blob, result.sourceUrl)
|
|
}
|
|
|
|
const filename = buildSingleFilename(song, extension)
|
|
saveFile(blob, filename)
|
|
|
|
activeDownloads.delete(song.id)
|
|
} catch (error) {
|
|
console.error(`处理失败: ${song.title}`, error)
|
|
downloadErrors.value.push({
|
|
id: song.id,
|
|
title: song.title,
|
|
artist: song.artist,
|
|
error: error.message
|
|
})
|
|
activeDownloads.delete(song.id)
|
|
} finally {
|
|
activeWorkers--
|
|
downloadedCount.value++
|
|
currentDownloadSong.value = queue.length > 0 ? getLocaleText('remainingSongs', '', queue.length) : locale.value.processComplete
|
|
}
|
|
}
|
|
}
|
|
|
|
// 启动并发工作线程
|
|
for (let i = 0; i < Math.min(concurrency, selectedSongsList.length); i++) {
|
|
workers.push(worker())
|
|
}
|
|
|
|
try {
|
|
await Promise.all(workers)
|
|
} finally {
|
|
terminateActiveEncoderWorker()
|
|
if (audioContext) {
|
|
audioContext.close()
|
|
}
|
|
}
|
|
|
|
currentDownloadSong.value = ''
|
|
processingStatus.value = ''
|
|
|
|
// 下载完成通知
|
|
if (window.$showNotification) {
|
|
const successCount = downloadedCount.value - downloadErrors.value.length
|
|
if (downloadErrors.value.length === 0) {
|
|
window.$showNotification(getLocaleText('downloadSuccess', '', successCount), 'success')
|
|
} else {
|
|
window.$showNotification(
|
|
getLocaleText('downloadCompleteWithCount', '', successCount, downloadErrors.value.length),
|
|
'warning'
|
|
)
|
|
}
|
|
}
|
|
|
|
// 无错误自动关闭
|
|
if (downloadErrors.value.length === 0) {
|
|
setTimeout(() => {
|
|
downloading.value = false
|
|
currentTaskType.value = ''
|
|
closeDialog()
|
|
}, 2000)
|
|
} else {
|
|
downloading.value = false
|
|
currentTaskType.value = ''
|
|
}
|
|
}
|
|
|
|
// 监听弹窗显示
|
|
watch(
|
|
() => props.show,
|
|
(newShow) => {
|
|
if (newShow) {
|
|
selectedSongs.value = new Set(props.songs.map((song) => song.song.id))
|
|
// 重置状态
|
|
if (!downloading.value) {
|
|
downloadedCount.value = 0
|
|
totalCount.value = 0
|
|
currentDownloadSong.value = ''
|
|
downloadErrors.value = []
|
|
activeDownloads.clear()
|
|
}
|
|
// 优先使用上次保存的音质偏好,否则使用默认值
|
|
const savedQuality = localStorage.getItem('voicehub_quality_preset')
|
|
if (savedQuality) {
|
|
const qualityNum = Number(savedQuality)
|
|
// 检查保存的音质是否在当前可用选项中
|
|
const isQualityAvailable = extendedQualityOptions.value.some(
|
|
(opt) => opt.value === qualityNum
|
|
)
|
|
if (isQualityAvailable) {
|
|
selectedQuality.value = qualityNum
|
|
} else {
|
|
// 如果不可用,回退到第一个可用选项
|
|
selectedQuality.value = extendedQualityOptions.value[0]?.value || getQuality('netease')
|
|
}
|
|
} else {
|
|
selectedQuality.value = getQuality('netease')
|
|
}
|
|
|
|
// 加载预设
|
|
const savedPreset = localStorage.getItem('voicehub_filename_preset')
|
|
if (savedPreset) {
|
|
customFilename.value = savedPreset
|
|
}
|
|
|
|
const savedDbPreset = localStorage.getItem('voicehub_db_preset')
|
|
if (savedDbPreset) {
|
|
try {
|
|
// 尝试解析为 JSON 对象 (新格式)
|
|
const preset = JSON.parse(savedDbPreset)
|
|
// 确保是对象且包含属性
|
|
if (typeof preset === 'object' && preset !== null && 'enabled' in preset) {
|
|
normalizeAudio.value = preset.enabled
|
|
targetDb.value = preset.targetDb
|
|
} else {
|
|
// 可能是旧格式的单个数值,或者是其他异常数据
|
|
throw new Error('Invalid format')
|
|
}
|
|
} catch {
|
|
// 解析失败,说明是旧版纯数值格式
|
|
const val = parseFloat(savedDbPreset)
|
|
if (!isNaN(val)) {
|
|
targetDb.value = val
|
|
normalizeAudio.value = true // 旧版默认开启
|
|
|
|
// 顺便更新为新格式
|
|
const newPreset = {
|
|
enabled: true,
|
|
targetDb: val
|
|
}
|
|
localStorage.setItem('voicehub_db_preset', JSON.stringify(newPreset))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
const insertPlaceholder = (placeholder) => {
|
|
customFilename.value += (customFilename.value ? ' ' : '') + placeholder
|
|
}
|
|
|
|
// 保存文件名预设
|
|
const saveFilenamePreset = () => {
|
|
if (!customFilename.value) return
|
|
|
|
localStorage.setItem('voicehub_filename_preset', customFilename.value)
|
|
showPresetSaved.value = true
|
|
|
|
setTimeout(() => {
|
|
showPresetSaved.value = false
|
|
}, 2000)
|
|
}
|
|
|
|
// 保存音量预设
|
|
const saveDbPreset = () => {
|
|
const preset = {
|
|
enabled: normalizeAudio.value,
|
|
targetDb: targetDb.value
|
|
}
|
|
localStorage.setItem('voicehub_db_preset', JSON.stringify(preset))
|
|
showDbPresetSaved.value = true
|
|
|
|
setTimeout(() => {
|
|
showDbPresetSaved.value = false
|
|
}, 2000)
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
terminateActiveEncoderWorker()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
background: var(--panel-bg-alt);
|
|
border-radius: 10px;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
background: var(--panel-bg-hover);
|
|
}
|
|
|
|
.fade-enter-active,
|
|
.fade-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.fade-enter-from,
|
|
.fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.scale-enter-active,
|
|
.scale-leave-active {
|
|
transition:
|
|
transform 0.2s ease,
|
|
opacity 0.2s ease;
|
|
}
|
|
|
|
.scale-enter-from,
|
|
.scale-leave-to {
|
|
transform: scale(0.95);
|
|
opacity: 0;
|
|
}
|
|
|
|
.expand-enter-active,
|
|
.expand-leave-active {
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
max-height: 500px;
|
|
opacity: 1;
|
|
}
|
|
|
|
.expand-enter-from,
|
|
.expand-leave-to {
|
|
max-height: 0;
|
|
opacity: 0;
|
|
margin-top: 0 !important;
|
|
padding-top: 0 !important;
|
|
padding-bottom: 0 !important;
|
|
border-top-width: 0 !important;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
0% {
|
|
transform: translateX(-100%);
|
|
}
|
|
100% {
|
|
transform: translateX(200%);
|
|
}
|
|
}
|
|
</style>
|