mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-07-08 02:17:30 +08:00
fix(ui): 修复内存泄漏、搜索竞态、播客时长及颜色/指令边界问题
- MusicFullMobile: 卸载清理睡眠定时器 interval;getTextColors 补上调用括号 - loading 指令: 改为每元素独立实例、卸载真正销毁、class 用 classList,避免多个 v-loading 争用单例 - lyric/index: 歌词窗口 receive-lyric IPC 监听卸载时解绑 - SearchBar/SearchResult/list: 三处异步搜索加竞态守卫,丢弃过期返回避免旧结果覆盖新结果 - linearColor: 0 尺寸图片 NaN 兜底、渐变色标越界兜底 - utils/secondToMinute: 时长 >=1 小时进位为 h:mm:ss(修复播客时长显示 78:34)
This commit is contained in:
@@ -962,6 +962,12 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(autoScrollTimer.value);
|
||||
}
|
||||
|
||||
// 清理睡眠定时器倒计时刷新 interval,避免组件卸载后残留、闭包持有 store 无法回收
|
||||
if (sleepTimerInterval) {
|
||||
clearInterval(sleepTimerInterval);
|
||||
sleepTimerInterval = null;
|
||||
}
|
||||
|
||||
// 清理鼠标事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
@@ -1084,7 +1090,7 @@ const { getLrcStyle: originalLrcStyle } = useLyricProgress();
|
||||
|
||||
// 修改 getLrcStyle 函数
|
||||
const getLrcStyle = (index: number) => {
|
||||
const colors = textColors.value || getTextColors;
|
||||
const colors = textColors.value || getTextColors();
|
||||
const originalStyle = originalLrcStyle(index);
|
||||
|
||||
if (index === nowIndex.value) {
|
||||
|
||||
@@ -2,39 +2,46 @@ import { createVNode, render, VNode } from 'vue';
|
||||
|
||||
import Loading from './index.vue';
|
||||
|
||||
const vnode: VNode = createVNode(Loading) as VNode;
|
||||
// 每个使用 v-loading 的元素独立持有一个 Loading 实例,
|
||||
// 避免此前"模块级单例 vnode"导致的多个 v-loading 争用同一实例、
|
||||
// spinner 只出现在最后挂载元素上的问题。
|
||||
const instanceMap = new WeakMap<HTMLElement, VNode>();
|
||||
|
||||
export const vLoading = {
|
||||
// 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
|
||||
mounted: (el: HTMLElement) => {
|
||||
render(vnode, el);
|
||||
},
|
||||
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
|
||||
updated: (el: HTMLElement, binding: any) => {
|
||||
if (binding.value) {
|
||||
vnode?.component?.exposed?.show();
|
||||
} else {
|
||||
vnode?.component?.exposed?.hide();
|
||||
}
|
||||
// 动态添加删除自定义class: loading-parent
|
||||
formatterClass(el, binding);
|
||||
},
|
||||
// 绑定元素的父组件卸载后调用
|
||||
unmounted: () => {
|
||||
const setLoading = (el: HTMLElement, visible: boolean) => {
|
||||
const vnode = instanceMap.get(el);
|
||||
if (visible) {
|
||||
vnode?.component?.exposed?.show();
|
||||
} else {
|
||||
vnode?.component?.exposed?.hide();
|
||||
}
|
||||
};
|
||||
|
||||
export const vLoading = {
|
||||
// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
|
||||
mounted: (el: HTMLElement, binding: any) => {
|
||||
const vnode = createVNode(Loading);
|
||||
render(vnode, el);
|
||||
instanceMap.set(el, vnode);
|
||||
setLoading(el, !!binding.value);
|
||||
formatterClass(el, binding);
|
||||
},
|
||||
// 在绑定元素的父组件及他自己的所有子节点都更新后调用
|
||||
updated: (el: HTMLElement, binding: any) => {
|
||||
setLoading(el, !!binding.value);
|
||||
// 动态添加删除自定义class: loading-parent
|
||||
formatterClass(el, binding);
|
||||
},
|
||||
// 绑定元素的父组件卸载后调用:真正卸载组件实例,释放资源
|
||||
unmounted: (el: HTMLElement) => {
|
||||
render(null, el);
|
||||
instanceMap.delete(el);
|
||||
}
|
||||
};
|
||||
|
||||
function formatterClass(el: HTMLElement, binding: any) {
|
||||
const classStr = el.getAttribute('class');
|
||||
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
|
||||
if (binding.value) {
|
||||
if (tagetClass === -1) {
|
||||
el.setAttribute('class', `${classStr} loading-parent`);
|
||||
}
|
||||
} else if (tagetClass > -1) {
|
||||
const classArray: Array<string> = classStr?.split('') as string[];
|
||||
classArray.splice(tagetClass - 1, tagetClass + 15);
|
||||
el.setAttribute('class', classArray?.join(''));
|
||||
el.classList.add('loading-parent');
|
||||
} else {
|
||||
el.classList.remove('loading-parent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,14 +382,22 @@ const showSuggestions = ref(false);
|
||||
const suggestionsLoading = ref(false);
|
||||
const highlightedIndex = ref(-1);
|
||||
|
||||
// 竞态守卫:记录最后一次请求的关键词,响应乱序返回时丢弃过期结果
|
||||
let lastSuggestKeyword = '';
|
||||
const debouncedSuggest = useDebounceFn(async (kw: string) => {
|
||||
if (!kw.trim()) {
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
return;
|
||||
}
|
||||
lastSuggestKeyword = kw;
|
||||
suggestionsLoading.value = true;
|
||||
suggestions.value = await getSearchSuggestions(kw);
|
||||
const result = await getSearchSuggestions(kw);
|
||||
// 若期间输入已变化,丢弃这次结果,避免旧关键词建议覆盖新关键词
|
||||
if (kw !== lastSuggestKeyword) {
|
||||
return;
|
||||
}
|
||||
suggestions.value = result;
|
||||
suggestionsLoading.value = false;
|
||||
showSuggestions.value = suggestions.value.length > 0;
|
||||
highlightedIndex.value = -1;
|
||||
|
||||
@@ -45,16 +45,19 @@ export const calculateAnimationDelay = (index: any, baseDelay: number = 0.03): s
|
||||
return `${delay.toFixed(3)}s`;
|
||||
};
|
||||
|
||||
// 将秒转换为分钟和秒
|
||||
// 将秒转换为时长字符串:<1 小时为 mm:ss,>=1 小时进位为 h:mm:ss
|
||||
export const secondToMinute = (s: number) => {
|
||||
if (!s) {
|
||||
if (!s || s < 0) {
|
||||
return '00:00';
|
||||
}
|
||||
const minute: number = Math.floor(s / 60);
|
||||
const hour: number = Math.floor(s / 3600);
|
||||
const minute: number = Math.floor((s % 3600) / 60);
|
||||
const second: number = Math.floor(s % 60);
|
||||
const minuteStr: string = minute > 9 ? minute.toString() : `0${minute.toString()}`;
|
||||
const secondStr: string = second > 9 ? second.toString() : `0${second.toString()}`;
|
||||
return `${minuteStr}:${secondStr}`;
|
||||
const pad = (n: number): string => (n > 9 ? `${n}` : `0${n}`);
|
||||
if (hour > 0) {
|
||||
return `${hour}:${pad(minute)}:${pad(second)}`;
|
||||
}
|
||||
return `${pad(minute)}:${pad(second)}`;
|
||||
};
|
||||
|
||||
// 格式化数字 千,万, 百万, 千万,亿
|
||||
|
||||
@@ -115,6 +115,10 @@ const getAverageColor = (data: Uint8ClampedArray): number[] => {
|
||||
b += data[i + 2];
|
||||
count++;
|
||||
}
|
||||
// 0 尺寸图片(count 为 0)会得到 NaN,回退为中性灰避免生成 rgb(NaN,...)
|
||||
if (count === 0) {
|
||||
return [128, 128, 128];
|
||||
}
|
||||
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
|
||||
};
|
||||
|
||||
@@ -306,8 +310,13 @@ export const createGradientString = (
|
||||
colors: { r: number; g: number; b: number }[],
|
||||
percentages = [0, 50, 100]
|
||||
) => {
|
||||
const count = colors.length;
|
||||
return `linear-gradient(to bottom, ${colors
|
||||
.map((color, i) => `rgb(${color.r}, ${color.g}, ${color.b}) ${percentages[i]}%`)
|
||||
.map((color, i) => {
|
||||
// 当颜色数量与传入的 percentages 不一致时,按索引均匀分布,避免出现 undefined%
|
||||
const percent = percentages[i] ?? (count > 1 ? Math.round((i / (count - 1)) * 100) : 0);
|
||||
return `rgb(${color.r}, ${color.g}, ${color.b}) ${percent}%`;
|
||||
})
|
||||
.join(', ')})`;
|
||||
};
|
||||
|
||||
|
||||
@@ -150,6 +150,10 @@ const loadList = async (type: string, isLoadMore = false) => {
|
||||
offset: page.value * TOTAL_ITEMS
|
||||
};
|
||||
const { data } = await getListByCat(params);
|
||||
// 竞态守卫:返回时若分类已切换,丢弃过期结果,避免旧分类覆盖新分类
|
||||
if (type !== currentType.value) {
|
||||
return;
|
||||
}
|
||||
if (isLoadMore) {
|
||||
recommendList.value.push(...data.playlists);
|
||||
} else {
|
||||
|
||||
@@ -343,6 +343,7 @@ const showTranslation = computed(() => lyricSetting.value.showTranslation);
|
||||
|
||||
let hideControlsTimer: number | null = null;
|
||||
let removeMousePresenceListener: (() => void) | null = null;
|
||||
let removeReceiveLyricListener: (() => void) | null = null;
|
||||
|
||||
const isHovering = ref(false);
|
||||
|
||||
@@ -806,8 +807,8 @@ onMounted(() => {
|
||||
updateContainerHeight();
|
||||
window.addEventListener('resize', updateContainerHeight);
|
||||
|
||||
// 监听歌词数据
|
||||
windowData.electron.ipcRenderer.on('receive-lyric', (_, data) => {
|
||||
// 监听歌词数据(保存移除函数,卸载时解绑,避免窗口复用/HMR 时监听器叠加)
|
||||
const disposeReceiveLyric = windowData.electron.ipcRenderer.on('receive-lyric', (_, data) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
handleDataUpdate(parsedData);
|
||||
@@ -815,6 +816,9 @@ onMounted(() => {
|
||||
console.error('Error parsing lyric data:', error);
|
||||
}
|
||||
});
|
||||
if (typeof disposeReceiveLyric === 'function') {
|
||||
removeReceiveLyricListener = disposeReceiveLyric;
|
||||
}
|
||||
|
||||
// 通知主窗口歌词窗口已就绪,请求发送完整歌词数据
|
||||
windowData.electron.ipcRenderer.send('lyric-ready');
|
||||
@@ -847,6 +851,10 @@ onUnmounted(() => {
|
||||
removeMousePresenceListener();
|
||||
removeMousePresenceListener = null;
|
||||
}
|
||||
if (removeReceiveLyricListener) {
|
||||
removeReceiveLyricListener();
|
||||
removeReceiveLyricListener = null;
|
||||
}
|
||||
});
|
||||
|
||||
const checkTheme = () => {
|
||||
|
||||
@@ -381,6 +381,11 @@ const loadSearch = async (isLoadMore = false) => {
|
||||
offset: page.value * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
// 竞态守卫:请求返回时若关键词/类型已切换,丢弃这次过期结果
|
||||
if (keywords !== currentKeyword.value || type !== searchType.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const songs = data.result.songs || [];
|
||||
const albums = data.result.albums || [];
|
||||
const mvs = (data.result.mvs || []).map((item: any) => ({
|
||||
|
||||
Reference in New Issue
Block a user