Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ declare module 'vue' {
IconLucideMicVocal: typeof import('~icons/lucide/mic-vocal')['default']
IconLucideMinimize: typeof import('~icons/lucide/minimize')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
IconLucideMoreVertical: typeof import('~icons/lucide/more-vertical')['default']
IconLucideMusic: typeof import('~icons/lucide/music')['default']
IconLucidePause: typeof import('~icons/lucide/pause')['default']
IconLucidePlay: typeof import('~icons/lucide/play')['default']
Expand Down Expand Up @@ -135,15 +137,21 @@ declare module 'vue' {
IconLucideVolumeX: typeof import('~icons/lucide/volume-x')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
IconLucideZap: typeof import('~icons/lucide/zap')['default']
IconMaterialSymbolsCancelRounded: typeof import('~icons/material-symbols/cancel-rounded')['default']
IconMaterialSymbolsChatBubbleRounded: typeof import('~icons/material-symbols/chat-bubble-rounded')['default']
IconMaterialSymbolsCheckCircleRounded: typeof import('~icons/material-symbols/check-circle-rounded')['default']
IconMaterialSymbolsFavoriteOutlineRounded: typeof import('~icons/material-symbols/favorite-outline-rounded')['default']
IconMaterialSymbolsInfoRounded: typeof import('~icons/material-symbols/info-rounded')['default']
IconMaterialSymbolsPlaylistPlayRounded: typeof import('~icons/material-symbols/playlist-play-rounded')['default']
IconMaterialSymbolsShuffleRounded: typeof import('~icons/material-symbols/shuffle-rounded')['default']
IconMaterialSymbolsWarningRounded: typeof import('~icons/material-symbols/warning-rounded')['default']
IconSpHeartMode: typeof import('~icons/sp/heart-mode')['default']
IconSpLossless: typeof import('~icons/sp/lossless')['default']
IconSpPlayOrder: typeof import('~icons/sp/play-order')['default']
IconSpRepeatOff: typeof import('~icons/sp/repeat-off')['default']
LoginCookieDialog: typeof import('./src/components/modals/LoginCookieDialog.vue')['default']
LoginDialog: typeof import('./src/components/modals/LoginDialog.vue')['default']
LyricActions: typeof import('./src/components/player/FullPlayer/LyricActions.vue')['default']
LyricFormatOrderConfig: typeof import('./src/components/settings/custom/LyricFormatOrderConfig.vue')['default']
Lyrics: typeof import('./src/components/player/Lyrics/index.vue')['default']
LyricSourceOrderConfig: typeof import('./src/components/settings/custom/LyricSourceOrderConfig.vue')['default']
Expand All @@ -169,6 +177,8 @@ declare module 'vue' {
PopoverRoot: typeof import('reka-ui')['PopoverRoot']
PopoverTrigger: typeof import('reka-ui')['PopoverTrigger']
QualityControl: typeof import('./src/components/player/QualityControl.vue')['default']
QueuePanel: typeof import('./src/components/player/FullPlayer/QueuePanel.vue')['default']
QueuePopover: typeof import('./src/components/list/QueuePopover.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SAlert: typeof import('./src/components/ui/SAlert.vue')['default']
Expand Down Expand Up @@ -212,6 +222,7 @@ declare module 'vue' {
SNumberInput: typeof import('./src/components/ui/SNumberInput.vue')['default']
SongList: typeof import('./src/components/list/SongList.vue')['default']
SpeedDialog: typeof import('./src/components/modals/SpeedDialog.vue')['default']
SPerformanceMonitor: typeof import('./src/components/SPerformanceMonitor.vue')['default']
SPopover: typeof import('./src/components/ui/SPopover.vue')['default']
SPopselect: typeof import('./src/components/ui/SPopselect.vue')['default']
SRadio: typeof import('./src/components/ui/SRadio.vue')['default']
Expand Down
38 changes: 4 additions & 34 deletions electron/main/store/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { getByPath, setByPath } from "@shared/utils/path";

/**
* 创建无原型对象,防止 JSON 原型污染
* @returns 无原型对象
Expand All @@ -8,8 +10,8 @@ export const createPlainObject = (): Record<string, unknown> =>
/**
* 深度合并
* defaults 为基底,stored 覆盖已有值,缺失字段从 defaults 补全
* @param defaults 基底对象
* @param stored 存储对象
* @param defaults - 基底对象
* @param stored - 存储对象
* @returns 合并后的对象
*/
export const deepMerge = <T>(defaults: T, stored: unknown): T => {
Expand All @@ -32,35 +34,3 @@ export const deepMerge = <T>(defaults: T, stored: unknown): T => {
}
return result as T;
};

/**
* 通过 dot path 取值
* @param obj 对象
* @param dotPath 点号路径
* @returns 值
*/
export const getByPath = (obj: unknown, dotPath: string): unknown => {
const keys = dotPath.split(".");
let cur = obj as Record<string, unknown>;
for (const k of keys) {
if (cur == null || typeof cur !== "object") return undefined;
cur = cur[k] as Record<string, unknown>;
}
return cur;
};

/**
* 通过 dot path 赋值
* @param obj 对象
* @param dotPath 点号路径
* @param value 值
*/
export const setByPath = (obj: unknown, dotPath: string, value: unknown): void => {
const keys = dotPath.split(".");
let cur = obj as Record<string, unknown>;
for (let i = 0; i < keys.length - 1; i++) {
if (cur[keys[i]] == null || typeof cur[keys[i]] !== "object") cur[keys[i]] = {};
cur = cur[keys[i]] as Record<string, unknown>;
}
cur[keys[keys.length - 1]] = value;
};
32 changes: 32 additions & 0 deletions shared/utils/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* 通过 dot path 取嵌套值
* @param obj - 对象
* @param dotPath - 形如 "player.equalizer.bands"
* @returns 值;中间节点缺失返回 undefined
*/
export const getByPath = (obj: unknown, dotPath: string): unknown => {
const keys = dotPath.split(".");
let cur = obj as Record<string, unknown> | null | undefined;
for (const key of keys) {
if (cur == null || typeof cur !== "object") return undefined;
cur = cur[key] as Record<string, unknown> | null | undefined;
}
return cur;
};

/**
* 通过 dot path 写入嵌套值,缺失的中间对象会自动补齐
* @param obj - 目标对象
* @param dotPath - 形如 "player.equalizer.bands"
* @param value - 新值
*/
export const setByPath = (obj: unknown, dotPath: string, value: unknown): void => {
const keys = dotPath.split(".");
let cur = obj as Record<string, unknown>;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (cur[key] == null || typeof cur[key] !== "object") cur[key] = {};
cur = cur[key] as Record<string, unknown>;
}
cur[keys[keys.length - 1]] = value;
};
200 changes: 200 additions & 0 deletions src/components/SPerformanceMonitor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<script setup lang="ts">
type MetricKind = "fps" | "ms" | "mem";

interface Metric {
label: string;
format: (v: number) => string;
current: number;
min: number;
max: number;
history: number[];
}

const HISTORY_SIZE = 74;
const SAMPLE_INTERVAL_MS = 1000;

const metrics: Record<MetricKind, Metric> = {
fps: {
label: "FPS",
format: (v) => String(Math.round(v)),
current: 0,
min: Infinity,
max: 0,
history: [],
},
ms: {
label: "MS",
format: (v) => v.toFixed(1),
current: 0,
min: Infinity,
max: 0,
history: [],
},
mem: {
label: "MB",
format: (v) => v.toFixed(0),
current: 0,
min: Infinity,
max: 0,
history: [],
},
};

const kind = ref<MetricKind>("fps");
const label = ref(metrics.fps.label);
const display = ref("0");
const rangeText = ref("0–0");

const hasMemory =
typeof (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory
?.usedJSHeapSize === "number";

const availableKinds = computed<MetricKind[]>(() =>
hasMemory ? ["fps", "ms", "mem"] : ["fps", "ms"],
);

const canvas = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;

const refreshDisplay = (): void => {
const m = metrics[kind.value];
label.value = m.label;
display.value = m.format(m.current);
const min = m.min === Infinity ? 0 : m.min;
rangeText.value = `${m.format(min)}–${m.format(m.max)}`;
draw();
};

const cycle = (): void => {
const list = availableKinds.value;
const idx = list.indexOf(kind.value);
kind.value = list[(idx + 1) % list.length];
refreshDisplay();
};

const draw = (): void => {
if (!ctx || !canvas.value) return;
const w = canvas.value.width;
const h = canvas.value.height;
ctx.clearRect(0, 0, w, h);
const m = metrics[kind.value];
if (m.history.length === 0) return;
const min = m.min === Infinity ? 0 : m.min;
const max = m.max || 1;
const range = max - min || 1;
ctx.fillStyle = getComputedStyle(canvas.value).color;
const step = w / HISTORY_SIZE;
for (let index = 0; index < m.history.length; index++) {
const value = m.history[index];
const ratio = Math.max(0, Math.min(1, (value - min) / range));
const barH = Math.max(1, ratio * (h - 1));
ctx.fillRect(index * step, h - barH, Math.max(1, step - 0.5), barH);
}
};

let rafId = 0;
let lastFrame = 0;
let frames = 0;
let sampleStart = 0;
let lastMs = 0;

const tick = (now: number): void => {
if (lastFrame) lastMs = now - lastFrame;
lastFrame = now;
frames++;
if (now - sampleStart >= SAMPLE_INTERVAL_MS) {
const elapsed = now - sampleStart;
pushSample("fps", (frames * 1000) / elapsed);
pushSample("ms", lastMs);
if (hasMemory) {
const mem =
(performance as unknown as { memory: { usedJSHeapSize: number } }).memory.usedJSHeapSize /
1048576;
pushSample("mem", mem);
}
frames = 0;
sampleStart = now;
refreshDisplay();
}
rafId = requestAnimationFrame(tick);
};

const pushSample = (k: MetricKind, value: number): void => {
const m = metrics[k];
m.current = value;
if (value < m.min) m.min = value;
if (value > m.max) m.max = value;
m.history.push(value);
if (m.history.length > HISTORY_SIZE) m.history.shift();
};

/** 拖拽位置 */
const pos = ref<{ x: number; y: number } | null>(null);
const dragging = ref(false);

let dragStart: { px: number; py: number; ox: number; oy: number } | null = null;
let activePointerId = -1;
let moved = false;

const DRAG_THRESHOLD = 3;

const onPointerDown = (event: PointerEvent): void => {
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
dragStart = { px: event.clientX, py: event.clientY, ox: rect.left, oy: rect.top };
activePointerId = event.pointerId;
moved = false;
el.setPointerCapture(activePointerId);
};

const onPointerMove = (event: PointerEvent): void => {
if (!dragStart) return;
const dx = event.clientX - dragStart.px;
const dy = event.clientY - dragStart.py;
if (!moved && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
moved = true;
dragging.value = true;
pos.value = { x: dragStart.ox + dx, y: dragStart.oy + dy };
};

const onPointerUp = (event: PointerEvent): void => {
const el = event.currentTarget as HTMLElement;
if (activePointerId >= 0) el.releasePointerCapture(activePointerId);
activePointerId = -1;
dragStart = null;
dragging.value = false;
if (!moved) cycle();
};

onMounted(() => {
if (canvas.value) ctx = canvas.value.getContext("2d");
sampleStart = performance.now();
rafId = requestAnimationFrame(tick);
});

onBeforeUnmount(() => {
cancelAnimationFrame(rafId);
});
</script>

<template>
<div
class="fixed z-9999 px-2 py-1.5 rounded-lg select-none bg-surface-bright text-on-surface border border-solid border-outline-variant/30 shadow-lg flex flex-col gap-1 app-no-drag"
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
:style="
pos
? { top: `${pos.y}px`, left: `${pos.x}px`, right: 'auto' }
: { top: '5rem', right: '1rem' }
"
:title="`click to switch · ${rangeText}`"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<div class="flex items-baseline gap-1.5 leading-none">
<span class="text-sm font-semibold tabular-nums">{{ display }}</span>
<span class="text-[10px] tracking-wider opacity-50">{{ label }}</span>
</div>
<canvas ref="canvas" :width="74" :height="22" class="block text-on-surface/85" />
</div>
</template>
Loading
Loading