Compare commits

...

4 Commits

Author SHA1 Message Date
sqj
b07cc2359a fix: 修复歌曲播放缓存内存泄露问题 feat:歌曲播放出错自动切歌不是暂停 2025-10-09 01:47:07 +08:00
时迁酱
46756a8b09 Merge pull request #12 from GladerJ/main
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 16:51:53 +08:00
Glader
deb73fa789 修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 15:15:30 +08:00
sqj
910ab1ff10 docs: add 赞助名单 2025-10-07 22:24:40 +08:00
9 changed files with 275 additions and 128 deletions

View File

@@ -12,5 +12,7 @@
| RiseSun | 9.9 |
| **b站小友**:光牙阿普斯木兰 | 5 |
| 青禾 | 8.8 |
| li peng | 200 |
| **群友**XIZ | 3 |
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.4.0",
"version": "1.4.1",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",

View File

@@ -1,4 +1,4 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen } from 'electron'
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen, powerSaveBlocker } from 'electron'
import { configManager } from './services/ConfigManager'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
@@ -34,6 +34,7 @@ if (!gotTheLock) {
// console.log(res)
// })
let tray: Tray | null = null
let psbId: number | null = null
let mainWindow: BrowserWindow | null = null
let isQuitting = false
@@ -367,6 +368,22 @@ app.whenReady().then(() => {
}
})
// 阻止系统息屏 IPC开启/关闭)
ipcMain.handle('power-save-blocker:start', () => {
if (psbId == null) {
psbId = powerSaveBlocker.start('prevent-display-sleep')
}
return psbId
})
ipcMain.handle('power-save-blocker:stop', () => {
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
powerSaveBlocker.stop(psbId)
}
psbId = null
return true
})
createWindow()
createTray()

View File

@@ -8,6 +8,11 @@ const api = {
console.log('preload: 发送 window-minimize 事件')
ipcRenderer.send('window-minimize')
},
// 阻止系统息屏
powerSaveBlocker: {
start: () => ipcRenderer.invoke('power-save-blocker:start'),
stop: () => ipcRenderer.invoke('power-save-blocker:stop')
},
maximize: () => {
console.log('preload: 发送 window-maximize 事件')
ipcRenderer.send('window-maximize')

View File

@@ -28,6 +28,8 @@ const emit = defineEmits<{
const canvasRef = ref<HTMLCanvasElement>()
const animationId = ref<number>()
const analyser = ref<AnalyserNode>()
// 节流渲染,目标 ~30fps
const lastFrameTime = ref(0)
const dataArray = ref<Uint8Array>()
const resizeObserver = ref<ResizeObserver>()
const componentId = ref<string>(`visualizer-${Date.now()}-${Math.random()}`)
@@ -75,93 +77,87 @@ const initAudioAnalyser = () => {
}
// 绘制可视化
const draw = () => {
const draw = (ts?: number) => {
if (!canvasRef.value || !analyser.value || !dataArray.value) return
// 帧率节流 ~30fps
const now = ts ?? performance.now()
if (now - lastFrameTime.value < 33) {
animationId.value = requestAnimationFrame(draw)
return
}
lastFrameTime.value = now
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
if (!ctx) {
animationId.value = requestAnimationFrame(draw)
return
}
// 获取频域数据或生成模拟数据
if (analyser.value && dataArray.value) {
// 有真实音频分析器,获取真实数据
analyser.value.getByteFrequencyData(dataArray.value as Uint8Array<ArrayBuffer>)
} else {
// 没有音频分析器,生成模拟数据
const time = Date.now() * 0.001
const time = now * 0.001
for (let i = 0; i < dataArray.value.length; i++) {
// 生成基于时间的模拟频谱数据
const frequency = i / dataArray.value.length
const amplitude = Math.sin(time * 2 + frequency * 10) * 0.5 + 0.5
const bass = Math.sin(time * 4) * 0.3 + 0.7 // 低频变化
const bass = Math.sin(time * 4) * 0.3 + 0.7
dataArray.value[i] = Math.floor(amplitude * bass * 255 * (1 - frequency * 0.7))
}
}
// 计算低频音量 (80hz-120hz 范围)
// 假设采样率为 44100HzfftSize 为 256则每个频率 bin 约为 172Hz
// 80-120Hz 大约对应前 1-2 个 bin
const lowFreqStart = 0
const lowFreqEnd = Math.min(3, dataArray.value.length) // 取前几个低频 bin
// 计算低频音量(前 3 个 bin
let lowFreqSum = 0
for (let i = lowFreqStart; i < lowFreqEnd; i++) {
lowFreqSum += dataArray.value[i]
}
const lowFreqVolume = lowFreqSum / (lowFreqEnd - lowFreqStart) / 255
const lowBins = Math.min(3, dataArray.value.length)
for (let i = 0; i < lowBins; i++) lowFreqSum += dataArray.value[i]
emit('lowFreqUpdate', lowFreqSum / lowBins / 255)
// 发送低频音量给父组件
emit('lowFreqUpdate', lowFreqVolume)
// 完全清空画布
// 清屏
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 如果有背景色,再填充背景
// 背景
if (props.backgroundColor !== 'transparent') {
ctx.fillStyle = props.backgroundColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
// 使用容器的实际尺寸进行计算,因为 ctx 已经缩放过了
// 计算尺寸
const container = canvas.parentElement
if (!container) return
if (!container) {
animationId.value = requestAnimationFrame(draw)
return
}
const containerRect = container.getBoundingClientRect()
const canvasWidth = containerRect.width
const canvasHeight = props.height
// 计算对称柱状参数
// 柱状参数
const halfBarCount = Math.floor(props.barCount / 2)
const barWidth = canvasWidth / 2 / halfBarCount
const maxBarHeight = canvasHeight * 0.9
const centerX = canvasWidth / 2
// 绘制左右对称的频谱柱状图
// 每帧仅创建一次渐变(自底向上),减少对象分配
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, 0)
gradient.addColorStop(0, props.color)
gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)'))
ctx.fillStyle = gradient
// 绘制对称频谱
for (let i = 0; i < halfBarCount; i++) {
// 增强低频响应,让可视化更敏感
let barHeight = (dataArray.value[i] / 255) * maxBarHeight
// 对数据进行增强处理,让变化更明显
barHeight = Math.pow(barHeight / maxBarHeight, 0.6) * maxBarHeight
const y = canvasHeight - barHeight
// 创建渐变色
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, y)
gradient.addColorStop(0, props.color)
gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)'))
ctx.fillStyle = gradient
// 绘制左侧柱状图(从中心向左)
const leftX = centerX - (i + 1) * barWidth
ctx.fillRect(leftX, y, barWidth, barHeight)
// 绘制右侧柱状图(从中心向右)
const rightX = centerX + i * barWidth
ctx.fillRect(rightX, y, barWidth, barHeight)
}
// 继续动画
if (props.show && Audio.value.isPlay) {
animationId.value = requestAnimationFrame(draw)
}
@@ -286,6 +282,10 @@ onBeforeUnmount(() => {
analyser.value.disconnect()
analyser.value = undefined
}
// 通知管理器移除对该分析器的引用,防止 Map 持有导致 GC 不回收
try {
audioManager.removeAnalyser(componentId.value)
} catch {}
} catch (error) {
console.warn('清理音频资源时出错:', error)
}

View File

@@ -103,7 +103,7 @@ watch(
const isNetease =
props.songInfo && 'source' in props.songInfo && props.songInfo.source === 'wy'
const songinfo: any = _.cloneDeep(toRaw(props.songInfo))
console.log(songinfo)
if (isNetease) {
// 网易云音乐优先尝试ttml接口
try {
@@ -112,30 +112,26 @@ watch(
).text()) as any
if (!res || res.length < 100) throw new Error('ttml 无歌词')
parsedLyrics = parseTTML(res).lines
console.log('搜索到ttml歌词', parsedLyrics)
} catch {
// ttml失败后使用新的歌词API
const lyricData = await window.api.music.requestSdk('getLyric', {
source: 'wy',
songInfo: songinfo
})
console.log('网易云歌词数据:', lyricData)
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
console.log('网易云逐字歌词', lyricText)
parsedLyrics = parseYrc(lyricText)
console.log('使用网易云逐字歌词', parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
console.log('使用网易云普通歌词', parsedLyrics)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
console.log('网易云翻译歌词:', translatedline)
for (let i = 0; i < parsedLyrics.length; i++) {
if (translatedline[i] && translatedline[i].words[0]) {
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
@@ -152,7 +148,6 @@ watch(
source: source,
songInfo: cleanSongInfo
})
console.log(`${source}歌词数据:`, lyricData)
if (lyricData.crlyric) {
// 使用逐字歌词
@@ -162,16 +157,14 @@ watch(
} else {
parsedLyrics = parseYrc(lyricText)
}
console.log(`使用${source}逐字歌词`, parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
console.log(`使用${source}普通歌词`, parsedLyrics)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
console.log(`${source}翻译歌词:`, translatedline)
for (let i = 0; i < parsedLyrics.length; i++) {
if (translatedline[i] && translatedline[i].words[0]) {
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
@@ -182,10 +175,8 @@ watch(
if (parsedLyrics.length > 0) {
state.lyricLines = parsedLyrics
console.log('歌词加载成功', parsedLyrics.length)
} else {
state.lyricLines = []
console.log('未找到歌词或解析失败')
}
} catch (error) {
console.error('获取歌词失败:', error)
@@ -197,6 +188,7 @@ watch(
const bgRef = ref<BackgroundRenderRef | undefined>(undefined)
const lyricPlayerRef = ref<LyricPlayerRef | undefined>(undefined)
// 订阅音频事件,保持数据同步
const unsubscribeTimeUpdate = ref<(() => void) | undefined>(undefined)
const unsubscribePlay = ref<(() => void) | undefined>(undefined)
@@ -214,7 +206,6 @@ const useBlackText = ref(false)
async function updateTextColor() {
try {
useBlackText.value = await shouldUseBlackText(actualCoverImage.value)
console.log('使用黑色文本:', useBlackText.value)
} catch (error) {
console.error('获取对比色失败:', error)
useBlackText.value = false // 默认使用白色文本
@@ -224,14 +215,40 @@ async function updateTextColor() {
// 监听封面图片变化
watch(() => actualCoverImage.value, updateTextColor, { immediate: true })
// 在全屏播放显示时阻止系统息屏
const blockerActive = ref(false)
watch(
() => props.show,
async (visible) => {
try {
if (visible && !blockerActive.value) {
await (window as any)?.api?.powerSaveBlocker?.start?.()
blockerActive.value = true
} else if (!visible && blockerActive.value) {
await (window as any)?.api?.powerSaveBlocker?.stop?.()
blockerActive.value = false
}
} catch (e) {
console.error('powerSaveBlocker 切换失败:', e)
}
},
{ immediate: true }
)
// 组件挂载时初始化
onMounted(() => {
updateTextColor()
console.log('组件挂载完成', bgRef.value, lyricPlayerRef.value)
})
// 组件卸载前清理订阅
onBeforeUnmount(() => {
onBeforeUnmount(async () => {
// 组件卸载时确保恢复系统息屏
if (blockerActive.value) {
try {
await (window as any)?.api?.powerSaveBlocker?.stop?.()
} catch {}
blockerActive.value = false
}
// 取消订阅以防止内存泄漏
if (unsubscribeTimeUpdate.value) {
unsubscribeTimeUpdate.value()

View File

@@ -1,5 +1,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref, onActivated, onDeactivated } from 'vue'
import {
onMounted,
onUnmounted,
provide,
ref,
onActivated,
onDeactivated,
watch,
nextTick
} from 'vue'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
const audioStore = ControlAudioStore()
@@ -17,6 +26,26 @@ onMounted(() => {
// window.api.ping(handleEnded)
})
/**
* 监听 URL 变化,先重置旧音频再加载新音频,避免旧解码/缓冲滞留
*/
watch(
() => audioStore.Audio.url,
async (newUrl) => {
const a = audioMeta.value
if (!a) return
try {
a.pause()
} catch {}
a.removeAttribute('src')
a.load()
await nextTick()
// 模板绑定会把 src 更新为 newUrl这里再触发一次 load
if (newUrl) {
a.load()
}
}
)
// 组件被激活时(从缓存中恢复)
onActivated(() => {
console.log('音频组件被激活')
@@ -71,22 +100,29 @@ const handlePlay = (): void => {
audioStore.publish('play')
}
let rafId: number | null = null
const startSetupInterval = (): void => {
if (rafId !== null) return
const onFrame = () => {
if (audioMeta.value && !audioMeta.value.paused) {
audioStore.publish('timeupdate')
audioStore.setCurrentTime((audioMeta.value && audioMeta.value.currentTime) || 0)
requestAnimationFrame(onFrame)
}
rafId = requestAnimationFrame(onFrame)
}
requestAnimationFrame(onFrame)
rafId = requestAnimationFrame(onFrame)
}
const handlePause = (): void => {
audioStore.Audio.isPlay = false
audioStore.publish('pause')
// 停止单实例 rAF
if (rafId !== null) {
try {
cancelAnimationFrame(rafId)
} catch {}
rafId = null
}
}
const handleError = (event: Event): void => {
@@ -112,8 +148,23 @@ const handleCanPlay = (): void => {
onUnmounted(() => {
// 组件卸载时清空所有订阅者
window.api.pingService.stop()
try {
window.api.pingService.stop()
} catch {}
// 停止 rAF
if (rafId !== null) {
try {
cancelAnimationFrame(rafId)
} catch {}
rafId = null
}
if (audioMeta.value) {
try {
audioMeta.value.pause()
} catch {}
audioMeta.value.removeAttribute('src')
audioMeta.value.load()
}
audioStore.clearAllSubscribers()
})
</script>

View File

@@ -136,38 +136,45 @@ const playSong = async (song: SongList) => {
// 设置加载状态
isLoadingSong.value = true
// 检查是否需要恢复播放位置历史播放
// 检查是否需要恢复播放位置(历史播放)
const isHistoryPlay =
song.songmid === userInfo.value.lastPlaySongId &&
userInfo.value.currentTime !== undefined &&
userInfo.value.currentTime > 0
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = song.songmid
console.log(`准备恢复播放位置: ${pendingRestorePosition}`)
// 清除历史位置避免重复恢复
// 清除历史位置,避免重复恢复
userInfo.value.currentTime = 0
} else {
pendingRestorePosition = 0
pendingRestoreSongId = null
}
// 更新当前播放歌曲ID
// 立刻暂停当前播放 - 不等待渐变
if (Audio.value.isPlay && Audio.value.audio) {
Audio.value.isPlay = false
Audio.value.audio.pause()
// 恢复音量避免下次播放音量为0
Audio.value.audio.volume = Audio.value.volume / 100
}
// 立刻更新 UI 到新歌曲
songInfo.value.name = song.name
songInfo.value.singer = song.singer
songInfo.value.albumName = song.albumName
songInfo.value.img = song.img
userInfo.value.lastPlaySongId = song.songmid
// 如果播放列表是打开的滚动到当前播放歌曲
// 如果播放列表是打开的,滚动到当前播放歌曲
if (showPlaylist.value) {
nextTick(() => {
playlistDrawerRef.value?.scrollToCurrentSong()
})
}
// 更新歌曲信息并触发主题色更新
songInfo.value.name = song.name
songInfo.value.singer = song.singer
songInfo.value.albumName = song.albumName
songInfo.value.img = song.img
// 更新媒体会话元数据
mediaSessionController.updateMetadata({
title: song.name,
@@ -176,68 +183,85 @@ const playSong = async (song: SongList) => {
artworkUrl: song.img || defaultCoverImg
})
// 确保主题色更新
// 尝试获取 URL
let urlToPlay = ''
// 获取URL
// eslint-disable-next-line no-useless-catch
try {
urlToPlay = await getSongRealUrl(toRaw(song))
} catch (error) {
throw error
} catch (error: any) {
console.error('获取歌曲 URL 失败,播放下一首原歌曲:', error)
isLoadingSong.value = false
tryAutoNext('获取歌曲 URL 失败')
return
}
// 先停止当前播放
if (Audio.value.isPlay) {
const stopResult = stop()
if (stopResult && typeof stopResult.then === 'function') {
await stopResult
}
// 在切换前彻底重置旧音频,释放缓冲与解码器
if (Audio.value.audio) {
const a = Audio.value.audio
try {
a.pause()
} catch {}
a.removeAttribute('src')
a.load()
}
// 设置URL这会触发音频重新加载
// 设置 URL(这会触发音频重新加载)
setUrl(urlToPlay)
// 等待音频准备就绪
await waitForAudioReady()
await setColor()
songInfo.value = {
...song
}
// // 短暂延迟确保音频状态稳定
// await new Promise((resolve) => setTimeout(resolve, 100))
// 开始播放
try {
start()
} catch (error) {
console.error('启动播放失败:', error)
// 如果是 AbortError尝试重新播放
if ((error as { name: string }).name === 'AbortError') {
console.log('检测到 AbortError尝试重新播放...')
await new Promise((resolve) => setTimeout(resolve, 200))
try {
const retryResult = start()
if (retryResult && typeof retryResult.then === 'function') {
await retryResult
}
} catch (retryError) {
console.error('重试播放失败:', retryError)
throw retryError
}
} else {
throw error
}
// 更新完整歌曲信息
songInfo.value = { ...song }
/**
* 提前关闭加载状态
* 这样UI不会卡在“加载中”用户能立刻看到播放键切换
*/
isLoadingSong.value = false
/**
* 异步开始播放不await以免阻塞UI
*/
start()
.catch(async (error: any) => {
console.error('启动播放失败:', error)
tryAutoNext('启动播放失败')
})
.then(() => {
autoNextCount.value = 0
})
/**
* 注册事件监听确保浏览器播放事件触发时同步关闭loading
* (多一道保险)
*/
if (Audio.value.audio) {
Audio.value.audio.addEventListener(
'playing',
() => {
isLoadingSong.value = false
},
{ once: true }
)
Audio.value.audio.addEventListener(
'error',
() => {
isLoadingSong.value = false
},
{ once: true }
)
}
} catch (error: any) {
console.error('播放歌曲失败:', error)
MessagePlugin.error('播放失败,原因:' + error.message)
console.error('播放歌曲失败(外层捕获):', error)
tryAutoNext('播放歌曲失败')
// MessagePlugin.error('播放失败,原因:' + error.message)
isLoadingSong.value = false
} finally {
// 无论成功还是失败,都清除加载状态
// 最后的保险,确保加载状态一定会被关闭
isLoadingSong.value = false
}
}
provide('PlaySong', playSong)
// 歌曲信息
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
@@ -246,6 +270,23 @@ const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
// 歌曲加载状态
const isLoadingSong = ref(false)
// 自动下一首次数限制不超过当前列表的30%
const autoNextCount = ref(0)
const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3))
const tryAutoNext = (reason: string) => {
const limit = getAutoNextLimit()
MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`)
if (autoNextCount.value >= limit && autoNextCount.value > 2) {
MessagePlugin.error(
`自动下一首失败超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}`
)
return
}
autoNextCount.value++
playNext()
}
// 更新播放模式
const updatePlayMode = () => {
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]

View File

@@ -4,6 +4,8 @@ class AudioManager {
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()
private audioContexts = new WeakMap<HTMLAudioElement, AudioContext>()
private analysers = new Map<string, AnalyserNode>()
// 为每个 audioElement 复用一个分流器,避免重复断开重连主链路
private splitters = new WeakMap<HTMLAudioElement, GainNode>()
static getInstance(): AudioManager {
if (!AudioManager.instance) {
@@ -60,16 +62,19 @@ class AudioManager {
analyser.fftSize = fftSize
analyser.smoothingTimeConstant = 0.6
// 创建增益节点作为中介,避免直接断开主音频链
const gainNode = context.createGain()
gainNode.gain.value = 1.0
// 复用每个 audioElement 的分流器source -> splitter -> destination
let splitter = this.splitters.get(audioElement)
if (!splitter) {
splitter = context.createGain()
splitter.gain.value = 1.0
// 仅第一次建立主链路,不要断开已有连接,避免累积
source.connect(splitter)
splitter.connect(context.destination)
this.splitters.set(audioElement, splitter)
}
// 连接source -> gainNode -> analyser
// -> destination (保持音频播放)
source.disconnect() // 先断开所有连接
source.connect(gainNode)
gainNode.connect(context.destination) // 确保音频继续播放
gainNode.connect(analyser) // 连接到分析器
// 将分析器挂到分流器上,不影响主链路
splitter.connect(analyser)
// 存储分析器引用
this.analysers.set(id, analyser)
@@ -104,6 +109,15 @@ class AudioManager {
context.close()
}
// 断开并移除分流器
const splitter = this.splitters.get(audioElement)
if (splitter) {
try {
splitter.disconnect()
} catch {}
this.splitters.delete(audioElement)
}
this.audioSources.delete(audioElement)
this.audioContexts.delete(audioElement)