Skip to content

Commit aaf37ad

Browse files
committed
✨feat: 虚拟列表 & 桌面歌词优化
1 parent d041fc5 commit aaf37ad

6 files changed

Lines changed: 238 additions & 97 deletions

File tree

src/assets/data/lyricConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const config: LyricConfig = {
1414
position: "both",
1515
limitBounds: false,
1616
textBackgroundMask: false,
17+
backgroundMaskColor: "rgba(0, 0, 0, 0.5)",
1718
alwaysShowPlayInfo: false,
1819
};
1920

src/components/Setting/LyricsSetting.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,21 @@
608608
@update:value="saveDesktopLyricConfig"
609609
/>
610610
</n-card>
611+
<n-collapse-transition :show="desktopLyricConfig.textBackgroundMask">
612+
<n-card class="set-item">
613+
<div class="label">
614+
<n-text class="name">遮罩颜色</n-text>
615+
<n-text class="tip" :depth="3">设置背景遮罩的颜色和透明度</n-text>
616+
</div>
617+
<n-color-picker
618+
v-model:value="desktopLyricConfig.backgroundMaskColor"
619+
:show-alpha="true"
620+
:modes="['rgb', 'hex']"
621+
class="set"
622+
@complete="saveDesktopLyricConfig"
623+
/>
624+
</n-card>
625+
</n-collapse-transition>
611626
<n-card class="set-item">
612627
<div class="label">
613628
<n-text class="name">始终展示播放信息</n-text>

src/components/UI/VirtualScroll.vue

Lines changed: 70 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
left: 0,
1717
right: 0,
1818
transform: `translateY(${offsetY}px)`,
19-
willChange: 'transform',
2019
}"
2120
>
2221
<div
@@ -36,7 +35,6 @@
3635

3736
<script setup lang="ts">
3837
import { NScrollbar } from "naive-ui";
39-
import { useElementSize } from "@vueuse/core";
4038
4139
interface Props {
4240
/** 列表项数据 */
@@ -118,15 +116,21 @@ const initializeHeights = () => {
118116
};
119117
120118
// 更新累积高度
121-
const updateTops = () => {
119+
const updateTops = (fromIndex = 0) => {
122120
if (props.itemFixed) return;
123121
124-
itemTops.value = [];
125-
let top = 0;
126-
for (let i = 0; i < itemHeights.value.length; i++) {
127-
itemTops.value[i] = top;
128-
top += itemHeights.value[i];
122+
const heights = itemHeights.value;
123+
const tops =
124+
itemTops.value.length === heights.length ? itemTops.value : new Array(heights.length);
125+
126+
// 从变更位置开始计算
127+
let top = fromIndex > 0 ? tops[fromIndex - 1] + heights[fromIndex - 1] : 0;
128+
for (let i = fromIndex; i < heights.length; i++) {
129+
tops[i] = top;
130+
top += heights[i];
129131
}
132+
133+
itemTops.value = tops;
130134
};
131135
132136
// 列表总高度
@@ -159,33 +163,38 @@ const calculateVisibleRange = (currentScrollTop: number) => {
159163
const visibleCount = Math.ceil(vHeight / props.itemHeight);
160164
endIndex = startIndex + visibleCount;
161165
} else {
162-
// 动态高度模式
163-
let start = 0;
164-
let end = itemTops.value.length - 1;
165-
166-
while (start <= end) {
167-
const mid = Math.floor((start + end) / 2);
168-
const top = itemTops.value[mid];
169-
const bottom = top + itemHeights.value[mid];
170-
166+
// 动态高度模式 - 使用二分查找优化
167+
const tops = itemTops.value;
168+
const heights = itemHeights.value;
169+
const len = tops.length;
170+
171+
// 二分查找起始索引
172+
let lo = 0;
173+
let hi = len - 1;
174+
while (lo <= hi) {
175+
const mid = (lo + hi) >>> 1;
176+
const bottom = tops[mid] + heights[mid];
171177
if (bottom > currentScrollTop) {
172178
startIndex = mid;
173-
end = mid - 1;
179+
hi = mid - 1;
174180
} else {
175-
start = mid + 1;
181+
lo = mid + 1;
176182
}
177183
}
178184
179-
// 查找结束索引
185+
// 二分查找结束索引
180186
const viewportBottom = currentScrollTop + vHeight;
187+
lo = startIndex;
188+
hi = len - 1;
181189
endIndex = startIndex;
182-
183-
// 从 startIndex 开始向后查找
184-
for (let i = startIndex; i < itemTops.value.length; i++) {
185-
if (itemTops.value[i] > viewportBottom) {
186-
break;
190+
while (lo <= hi) {
191+
const mid = (lo + hi) >>> 1;
192+
if (tops[mid] <= viewportBottom) {
193+
endIndex = mid;
194+
lo = mid + 1;
195+
} else {
196+
hi = mid - 1;
187197
}
188-
endIndex = i;
189198
}
190199
}
191200
@@ -212,7 +221,7 @@ const offsetY = computed(() => {
212221
return actualStartIndex.value * props.itemHeight;
213222
}
214223
if (itemTops.value.length === 0) return 0;
215-
return itemTops.value[actualStartIndex.value];
224+
return Math.round(itemTops.value[actualStartIndex.value]);
216225
});
217226
218227
// 测量项目高度
@@ -246,28 +255,36 @@ const measureItemHeights = () => {
246255
}
247256
};
248257
258+
let rafId: number | null = null;
259+
let pendingScrollTarget: HTMLElement | null = null;
260+
261+
const processScroll = () => {
262+
rafId = null;
263+
const target = pendingScrollTarget;
264+
if (!target) return;
265+
266+
const { scrollTop: st, scrollHeight, clientHeight } = target;
267+
scrollTop.value = st;
268+
calculateVisibleRange(st);
269+
270+
// 触底检测
271+
if (scrollHeight - st - clientHeight < 50) {
272+
emit("reachBottom");
273+
}
274+
};
275+
249276
// 处理滚动事件
250-
let ticking = false;
251277
const handleScroll = (event: Event) => {
252278
const target = event.target as HTMLElement;
253279
if (!target) return;
254280
255281
// 触发外部事件
256282
emit("scroll", event);
257283
258-
if (!ticking) {
259-
requestAnimationFrame(() => {
260-
const { scrollTop: st, scrollHeight, clientHeight } = target;
261-
scrollTop.value = st;
262-
calculateVisibleRange(st);
263-
264-
// 触底检测
265-
if (scrollHeight - st - clientHeight < 50) {
266-
emit("reachBottom");
267-
}
268-
ticking = false;
269-
});
270-
ticking = true;
284+
// 合并多次滚动到一个 rAF
285+
pendingScrollTarget = target;
286+
if (rafId === null) {
287+
rafId = requestAnimationFrame(processScroll);
271288
}
272289
};
273290
@@ -312,14 +329,17 @@ defineExpose({
312329
getScrollTop,
313330
});
314331
332+
// 防抖高度测量
333+
const debouncedMeasure = useDebounceFn(measureItemHeights, 50);
334+
315335
// 监听数据变化
316336
watch(
317337
() => props.items,
318338
() => {
319339
initializeHeights();
320340
calculateVisibleRange(scrollTop.value);
321341
// 重新测量高度
322-
nextTick(measureItemHeights);
342+
nextTick(debouncedMeasure);
323343
},
324344
{ deep: false },
325345
);
@@ -343,13 +363,12 @@ watch(
343363
() => [actualStartIndex.value, actualEndIndex.value],
344364
() => {
345365
if (!props.itemFixed) {
346-
nextTick(measureItemHeights);
366+
nextTick(debouncedMeasure);
347367
}
348368
},
349369
{ flush: "post" },
350370
);
351371
352-
// 组件挂载后初始化
353372
onMounted(() => {
354373
initializeHeights();
355374
// 等待 DOM 渲染和容器尺寸确定
@@ -362,6 +381,13 @@ onMounted(() => {
362381
if (!props.itemFixed) measureItemHeights();
363382
});
364383
});
384+
385+
onUnmounted(() => {
386+
if (rafId !== null) {
387+
cancelAnimationFrame(rafId);
388+
rafId = null;
389+
}
390+
});
365391
</script>
366392

367393
<style scoped>

src/layout/AppLayout.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,6 @@ onMounted(() => {
144144
}
145145
}
146146
&.show-player {
147-
// #main-sider {
148-
// margin-bottom: 80px;
149-
// }
150147
#main-content {
151148
bottom: 80px;
152149
}

src/types/desktop-lyric.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export interface LyricConfig {
5151
limitBounds: boolean;
5252
/** 文本背景遮罩 */
5353
textBackgroundMask: boolean;
54+
/** 文本背景遮罩颜色 */
55+
backgroundMaskColor: string;
5456
/** 始终展示播放信息 */
5557
alwaysShowPlayInfo: boolean;
5658
}

0 commit comments

Comments
 (0)