feat: 完善歌词窗口 IPC

This commit is contained in:
imsyy
2025-11-05 18:21:17 +08:00
parent a1be1e16b2
commit 9fcd5b0e98
8 changed files with 327 additions and 92 deletions

View File

@@ -13,56 +13,82 @@ const initLyricIpc = (): void => {
// 歌词窗口
let lyricWin: BrowserWindow | null = null;
/**
* 窗口是否存活
* @param win 窗口实例
* @returns 是否存活
*/
const isWinAlive = (win: BrowserWindow | null): win is BrowserWindow =>
!!win && !win.isDestroyed();
// 切换桌面歌词
ipcMain.on("toggle-desktop-lyric", (_event, val: boolean) => {
if (val) {
if (!lyricWin) {
if (!isWinAlive(lyricWin)) {
lyricWin = lyricWindow.create();
// 监听关闭,置空引用,防止后续调用报错
lyricWin?.on("closed", () => {
lyricWin = null;
});
// 设置位置
const { x, y } = store.get("lyric");
const xPos = Number(x);
const yPos = Number(y);
if (Number.isFinite(xPos) && Number.isFinite(yPos)) {
lyricWin?.setPosition(Math.round(xPos), Math.round(yPos));
}
} else {
lyricWin?.show();
lyricWin.show();
}
if (isWinAlive(lyricWin)) {
lyricWin.setAlwaysOnTop(true, "screen-saver");
}
lyricWin?.setAlwaysOnTop(true, "screen-saver");
} else {
// 关闭:不销毁窗口,直接隐藏,保留位置与状态
if (!lyricWin) return;
if (!isWinAlive(lyricWin)) return;
lyricWin.hide();
}
});
// 向主窗口发送事件
ipcMain.on("send-to-main", (_, eventName, ...args) => {
mainWin?.webContents.send(eventName, ...args);
});
// 更新歌词窗口数据
ipcMain.on("update-desktop-lyric-data", (_, lyricData) => {
if (!lyricData || !lyricWin) return;
lyricWin?.webContents.send("update-desktop-lyric-data", lyricData);
if (!lyricData || !isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", lyricData);
});
// 更新歌词窗口配置
ipcMain.on("update-desktop-lyric-option", (_, option) => {
if (!option || !lyricWin) return;
lyricWin?.webContents.send("desktop-lyric-option-change", option);
if (!option || !isWinAlive(lyricWin)) return;
lyricWin.webContents.send("desktop-lyric-option-change", option);
});
// 播放状态更改
ipcMain.on("play-status-change", (_, status) => {
if (!status || !lyricWin) return;
lyricWin?.webContents.send("update-desktop-lyric-data", { playStatus: status });
if (!isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", { playStatus: status });
});
// 音乐名称更改
ipcMain.on("play-song-change", (_, title) => {
if (!title || !lyricWin) return;
lyricWin?.webContents.send("update-desktop-lyric-data", { playName: title });
if (!title || !isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", { playName: title });
});
// 音乐歌词更改
ipcMain.on("play-lyric-change", (_, lyricData) => {
if (!lyricData || !lyricWin) return;
lyricWin?.webContents.send("update-desktop-lyric-data", lyricData);
if (!lyricData || !isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", lyricData);
});
// 获取窗口位置
ipcMain.handle("get-window-bounds", () => {
if (!lyricWin) return {};
return lyricWin?.getBounds();
if (!isWinAlive(lyricWin)) return {};
return lyricWin.getBounds();
});
// 获取屏幕尺寸
@@ -71,24 +97,40 @@ const initLyricIpc = (): void => {
return { width, height };
});
// 获取多屏虚拟边界(支持负坐标)
ipcMain.handle("get-virtual-screen-bounds", () => {
const displays = screen.getAllDisplays();
const bounds = displays.map((d) => d.workArea);
const minX = Math.min(...bounds.map((b) => b.x));
const minY = Math.min(...bounds.map((b) => b.y));
const maxX = Math.max(...bounds.map((b) => b.x + b.width));
const maxY = Math.max(...bounds.map((b) => b.y + b.height));
return { minX, minY, maxX, maxY };
});
// 移动窗口
ipcMain.on("move-window", (_, x, y, width, height) => {
if (!lyricWin) return;
if (!isWinAlive(lyricWin)) return;
lyricWin.setBounds({ x, y, width, height });
// 保存配置
store.set("lyric", { ...store.get("lyric"), x, y, width, height });
// 保持置顶
lyricWin?.setAlwaysOnTop(true, "screen-saver");
});
// 更新高度
ipcMain.on("update-window-height", (_, height) => {
if (!lyricWin) return;
if (!isWinAlive(lyricWin)) return;
const { width } = lyricWin.getBounds();
// 更新窗口高度
lyricWin.setBounds({ width, height });
});
// 请求歌词数据及配置
ipcMain.on("request-desktop-lyric-data", () => {
if (!isWinAlive(lyricWin)) return;
// 触发窗口更新
mainWin?.webContents.send("request-desktop-lyric-data");
});
// 获取配置
ipcMain.handle("get-desktop-lyric-option", () => {
return store.get("lyric");
@@ -98,7 +140,7 @@ const initLyricIpc = (): void => {
ipcMain.on("set-desktop-lyric-option", (_, option, callback: boolean = false) => {
store.set("lyric", option);
// 触发窗口更新
if (callback && lyricWin) {
if (callback && isWinAlive(lyricWin)) {
lyricWin.webContents.send("desktop-lyric-option-change", option);
}
mainWin?.webContents.send("desktop-lyric-option-change", option);
@@ -111,14 +153,14 @@ const initLyricIpc = (): void => {
// 关闭桌面歌词
ipcMain.on("closeDesktopLyric", () => {
if (!lyricWin) return;
if (!isWinAlive(lyricWin)) return;
lyricWin.hide();
mainWin?.webContents.send("closeDesktopLyric");
});
// 锁定/解锁桌面歌词
ipcMain.on("toogleDesktopLyricLock", (_, isLock: boolean) => {
if (!lyricWin) return;
if (!isWinAlive(lyricWin)) return;
// 是否穿透
if (isLock) {
lyricWin.setIgnoreMouseEvents(true, { forward: true });

View File

@@ -29,6 +29,16 @@ const initWindowsIpc = (): void => {
if (isMaximized) mainWin?.maximize();
mainWin?.show();
mainWin?.focus();
// 解决窗口不立即显示
mainWin?.setAlwaysOnTop(true);
// 100ms 后取消置顶
const timer = setTimeout(() => {
if (mainWin && !mainWin.isDestroyed()) {
mainWin.setAlwaysOnTop(false);
mainWin.focus();
clearTimeout(timer);
}
}, 100);
// 初始化缩略图工具栏
if (mainWin) initThumbar(mainWin);
});
@@ -64,6 +74,7 @@ const initWindowsIpc = (): void => {
// 显示
ipcMain.on("win-show", () => {
mainWin?.show();
mainWin?.focus();
});
// 重启

View File

@@ -1,5 +1,5 @@
import { screen } from "electron";
import { storeLog } from "../logger";
import type { LyricConfig } from "../../../src/types/desktop-lyric";
import Store from "electron-store";
storeLog.info("🌱 Store init");
@@ -21,6 +21,8 @@ export interface StoreType {
y?: number;
width?: number;
height?: number;
// 配置
config?: LyricConfig;
};
proxy: string;
}
@@ -40,10 +42,22 @@ export const useStore = () => {
fontSize: 30,
mainColor: "#fff",
shadowColor: "rgba(0, 0, 0, 0.5)",
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
x: 0,
y: 0,
width: 800,
height: 180,
config: {
isLock: false,
playedColor: "#fe7971",
unplayedColor: "#ccc",
stroke: "#000",
strokeWidth: 2,
fontFamily: "system-ui",
fontSize: 24,
isDoubleLine: true,
position: "both",
limitBounds: false,
},
},
proxy: "",
},

View File

@@ -12,7 +12,7 @@ export const createWindow = (
title: appName,
width: 1280,
height: 720,
frame: false, // 创建后是否显示窗口
frame: false, // 是否显示窗口边框
center: true, // 窗口居中
icon, // 窗口图标
autoHideMenuBar: true, // 隐藏菜单栏

View File

@@ -38,25 +38,27 @@ class LyricWindow {
height: height || 180,
minWidth: 440,
minHeight: 120,
maxWidth: 1600,
maxHeight: 300,
center: !(x && y), // 没有指定位置时居中显示
// maxWidth: 1600,
// maxHeight: 300,
// 窗口位置
x,
y,
transparent: true,
backgroundColor: "rgba(0, 0, 0, 0)",
// transparent: true,
// backgroundColor: "rgba(0, 0, 0, 0)",
alwaysOnTop: true,
resizable: true,
movable: true,
show: false,
// 不在任务栏显示
skipTaskbar: true,
// 窗口不能最小化
minimizable: false,
// 窗口不能最大化
maximizable: false,
// 窗口不能进入全屏状态
fullscreenable: false,
// skipTaskbar: true,
// // 窗口不能最小化
// minimizable: false,
// // 窗口不能最大化
// maximizable: false,
// // 窗口不能进入全屏状态
// fullscreenable: false,
frame: true,
});
if (!this.win) return null;
// 加载地址

View File

@@ -3,16 +3,16 @@ import { LyricType } from "@/types/main";
/** 桌面歌词数据 */
export interface LyricData {
/** 播放歌曲名称 */
playName: string;
playName?: string;
/** 播放状态 */
playStatus: boolean;
playStatus?: boolean;
/** 播放进度 */
progress: number;
progress?: number;
/** 歌词数据 */
lrcData: LyricType[];
yrcData: LyricType[];
lrcData?: LyricType[];
yrcData?: LyricType[];
/** 歌词播放索引 */
lyricIndex: number;
lyricIndex?: number;
}
/** 桌面歌词配置 */
@@ -31,10 +31,22 @@ export interface LyricConfig {
fontFamily: string;
/** 字体大小 */
fontSize: number;
/** 行高 */
lineHeight: number;
/** 是否双行 */
isDoubleLine: boolean;
/** 文本排版位置 */
position: "left" | "center" | "right" | "both";
/** 是否限制在屏幕边界内拖动 */
limitBounds: boolean;
}
/**
*
*/
interface RenderLine {
/** 歌词文本 */
text: string;
/** 唯一键 */
key: string;
/** 是否高亮 */
active: boolean;
}

View File

@@ -3,6 +3,8 @@ import { openUpdateApp } from "./modal";
import { useMusicStore, useDataStore, useStatusStore } from "@/stores";
import { toLikeSong } from "./auth";
import player from "./player";
import { cloneDeep } from "lodash-es";
import { getPlayerInfo } from "./player-utils/song";
// 关闭更新状态
const closeUpdateStatus = () => {
@@ -39,6 +41,23 @@ const initIpc = () => {
// 桌面歌词开关
window.electron.ipcRenderer.on("toogleDesktopLyric", () => player.toggleDesktopLyric());
window.electron.ipcRenderer.on("closeDesktopLyric", () => player.toggleDesktopLyric());
// 请求歌词数据
window.electron.ipcRenderer.on("request-desktop-lyric-data", () => {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
if (player) {
window.electron.ipcRenderer.send(
"update-desktop-lyric-data",
cloneDeep({
playStatus: statusStore.playStatus,
playName: getPlayerInfo() ?? "未知歌曲",
lrcData: musicStore.songLyric.lrcData ?? [],
yrcData: musicStore.songLyric.yrcData ?? [],
lyricIndex: statusStore.lyricIndex,
}),
);
}
});
// 无更新
window.electron.ipcRenderer.on("update-not-available", () => {
closeUpdateStatus();

View File

@@ -3,15 +3,15 @@
<div
ref="desktopLyricsRef"
:class="[
'desktop-lyrics',
'desktop-lyric',
{
locked: lyricConfig.isLock,
},
]"
>
<div class="header" align="center" justify="space-between">
<n-flex :wrap="false" align="center" justify="flex-start" size="small">
<div class="menu-btn" title="返回应用">
<n-flex :wrap="false" align="center" justify="flex-start" size="small" @pointerdown.stop>
<div class="menu-btn" title="返回应用" @click.stop="sendToMain('win-show')">
<SvgIcon name="Music" />
</div>
<div class="menu-btn" title="增加字体大小">
@@ -22,18 +22,22 @@
</div>
<span class="song-name">{{ lyricData.playName }}</span>
</n-flex>
<n-flex :wrap="false" align="center" justify="center" size="small">
<div class="menu-btn" title="上一曲">
<n-flex :wrap="false" align="center" justify="center" size="small" @pointerdown.stop>
<div class="menu-btn" title="上一曲" @click.stop="sendToMain('playPrev')">
<SvgIcon name="SkipPrev" />
</div>
<div class="menu-btn" :title="lyricData.playStatus ? '暂停' : '播放'">
<div
class="menu-btn"
:title="lyricData.playStatus ? '暂停' : '播放'"
@click.stop="sendToMain('playOrPause')"
>
<SvgIcon :name="lyricData.playStatus ? 'Pause' : 'Play'" />
</div>
<div class="menu-btn" title="下一曲">
<div class="menu-btn" title="下一曲" @click.stop="sendToMain('playNext')">
<SvgIcon name="SkipNext" />
</div>
</n-flex>
<n-flex :wrap="false" align="center" justify="flex-end" size="small">
<n-flex :wrap="false" align="center" justify="flex-end" size="small" @pointerdown.stop>
<div class="menu-btn" title="锁定">
<SvgIcon name="Lock" />
</div>
@@ -45,25 +49,32 @@
</div>
</n-flex>
</div>
<div
<n-flex
:style="{
fontSize: lyricConfig.fontSize + 'px',
fontFamily: lyricConfig.fontFamily,
lineHeight: lyricConfig.lineHeight + 'px',
}"
class="lyrics-container"
:class="['lyric-container', lyricConfig.position]"
vertical
>
<span v-for="(item, index) in displayLyricLines" :key="index" class="lyric-line">
{{ item }}
<span
v-for="line in renderLyricLines"
:key="line.key"
:class="['lyric-line', { active: line.active }]"
:style="{
color: line.active ? lyricConfig.playedColor : lyricConfig.unplayedColor,
}"
>
{{ line.text }}
</span>
</div>
</n-flex>
</div>
</n-config-provider>
</template>
<script setup lang="ts">
import { Position } from "@vueuse/core";
import { LyricConfig, LyricData } from ".";
import { LyricConfig, LyricData, RenderLine } from "@/types/desktop-lyric";
// 桌面歌词数据
const lyricData = reactive<LyricData>({
@@ -78,71 +89,172 @@ const lyricData = reactive<LyricData>({
// 桌面歌词配置
const lyricConfig = reactive<LyricConfig>({
isLock: false,
playedColor: "#fff",
playedColor: "#fe7971",
unplayedColor: "#ccc",
stroke: "#000",
strokeWidth: 2,
fontFamily: "system-ui",
fontSize: 24,
lineHeight: 48,
isDoubleLine: false,
position: "center",
isDoubleLine: true,
position: "both",
limitBounds: false,
});
// 桌面歌词元素
const desktopLyricsRef = useTemplateRef<HTMLElement>("desktopLyricsRef");
// 需要显示的歌词行
const displayLyricLines = computed<string[]>(() => {
// 优先使用逐字歌词,无则使用普通歌词
const lyrics = lyricData.yrcData.length ? lyricData.yrcData : lyricData.lrcData;
if (!lyrics.length) return ["纯音乐,请欣赏"];
// 当前播放歌词索引
let idx = lyricData.lyricIndex;
if (idx < 0) idx = 0; // 开头之前按首句处理
// 当前与下一句
/**
* 渲染的歌词行
* @returns 渲染的歌词行数组
*/
const renderLyricLines = computed<RenderLine[]>(() => {
const lyrics = lyricData?.yrcData?.length ? lyricData.yrcData : lyricData.lrcData;
if (!lyrics?.length) {
return [{ text: "纯音乐,请欣赏", key: "placeholder", active: true }];
}
let idx = lyricData?.lyricIndex ?? -1;
// 显示歌名
if (idx < 0) {
return [{ text: lyricData.playName ?? "未知歌曲", key: "placeholder", active: true }];
}
const current = lyrics[idx];
const next = lyrics[idx + 1];
if (!current) return [];
// 若当前句有翻译,无论单/双行设置都显示两行:原文 + 翻译
// 翻译
if (current.tran && current.tran.trim().length > 0) {
return [current.content, current.tran];
const lines: RenderLine[] = [
{ text: current.content, key: `${idx}:orig`, active: true },
{ text: current.tran, key: `${idx}:tran`, active: false },
];
return lines.filter((l) => l.text && l.text.trim().length > 0);
}
// 单行:仅返回当前句原文
// 单行:仅当前句原文,高亮
if (!lyricConfig.isDoubleLine) {
return [current.content];
return [{ text: current.content, key: `${idx}:orig`, active: true }].filter(
(l) => l.text && l.text.trim().length > 0,
);
}
// 双行:两行内容交替
// 双行交替:只高亮当前句所在行
const isEven = idx % 2 === 0;
if (isEven) {
// 偶数句:第一行当前句,第二行下一句
return [current.content, next?.content ?? ""];
const lines: RenderLine[] = [
{ text: current.content, key: `${idx}:orig`, active: true },
{ text: next?.content ?? "", key: `${idx + 1}:next`, active: false },
];
return lines.filter((l) => l.text && l.text.trim().length > 0);
}
// 奇数句:第一行下一句,第二行当前句
return [next?.content ?? "", current.content];
const lines: RenderLine[] = [
{ text: next?.content ?? "", key: `${idx + 1}:next`, active: false },
{ text: current.content, key: `${idx}:orig`, active: true },
];
return lines.filter((l) => l.text && l.text.trim().length > 0);
});
// 桌面歌词拖动
const lyricDragMove = (position: Position) => {
console.log(position);
// 拖拽窗口状态
const dragState = reactive({
isDragging: false,
startX: 0,
startY: 0,
startWinX: 0,
startWinY: 0,
winWidth: 0,
winHeight: 0,
});
/**
* 桌面歌词拖动开始
* @param _position 拖动位置
* @param event 指针事件
*/
const lyricDragStart = async (_position: Position, event: PointerEvent) => {
if (lyricConfig.isLock) return;
dragState.isDragging = true;
const { x, y, width, height } = await window.electron.ipcRenderer.invoke("get-window-bounds");
dragState.startX = (event?.screenX ?? 0) as number;
dragState.startY = (event?.screenY ?? 0) as number;
dragState.startWinX = x as number;
dragState.startWinY = y as number;
dragState.winWidth = width as number;
dragState.winHeight = height as number;
};
/**
* 桌面歌词拖动移动
* @param _position 拖动位置
* @param event 指针事件
*/
const lyricDragMove = async (_position: Position, event: PointerEvent) => {
if (!dragState.isDragging || lyricConfig.isLock) return;
const screenX = (event?.screenX ?? 0) as number;
const screenY = (event?.screenY ?? 0) as number;
let newWinX = dragState.startWinX + (screenX - dragState.startX);
let newWinY = dragState.startWinY + (screenY - dragState.startY);
// 可选:限制在屏幕边界(支持多屏)
if (lyricConfig.limitBounds) {
const { minX, minY, maxX, maxY } = await window.electron.ipcRenderer.invoke(
"get-virtual-screen-bounds",
);
newWinX = Math.max(minX as number, Math.min((maxX as number) - dragState.winWidth, newWinX));
newWinY = Math.max(minY as number, Math.min((maxY as number) - dragState.winHeight, newWinY));
}
window.electron.ipcRenderer.send(
"move-window",
newWinX,
newWinY,
dragState.winWidth,
dragState.winHeight,
);
};
// 监听桌面歌词拖动
useDraggable(desktopLyricsRef, {
onMove: lyricDragMove,
onStart: (position, event) => {
lyricDragStart(position, event);
},
onMove: (position, event) => {
lyricDragMove(position, event);
},
onEnd: () => {
dragState.isDragging = false;
},
});
// 发送至主窗口
const sendToMain = (eventName: string, ...args: any[]) => {
// 特殊处理
if (eventName === "win-show") {
window.electron.ipcRenderer.send("win-show");
return;
}
window.electron.ipcRenderer.send("send-to-main", eventName, ...args);
};
// 处理歌词数据
const handleLyricData = (data: LyricData) => {
Object.assign(lyricData, data);
};
onMounted(() => {
// 接收歌词配置
// 接收歌词数据
window.electron.ipcRenderer.on("update-desktop-lyric-data", (_event, data: LyricData) => {
handleLyricData(data);
});
// 请求歌词数据及配置
window.electron.ipcRenderer.send("request-desktop-lyric-data");
});
</script>
<style scoped lang="scss">
.desktop-lyrics {
.n-config-provider {
width: 100%;
height: 100%;
}
.desktop-lyric {
color: #fff;
background-color: transparent;
padding: 12px;
border-radius: 12px;
height: 100%;
overflow: hidden;
transition: background-color 0.3s;
cursor: move;
@@ -191,20 +303,43 @@ onMounted(() => {
}
}
}
.lyric-container {
padding: 0 8px;
&.center {
align-items: center;
}
&.right {
align-items: flex-end;
}
&.both {
.lyric-line {
&:nth-child(2n) {
margin-left: auto;
}
}
}
}
&:hover {
&:not(.lock) {
&:not(.locked) {
background-color: rgba(0, 0, 0, 0.3);
.header {
opacity: 1;
}
}
}
&.locked {
pointer-events: none;
cursor: default;
.header {
opacity: 0;
}
}
}
</style>
<style>
<!-- <style>
body {
background-color: transparent !important;
/* background-image: url("https://picsum.photos/1920/1080"); */
}
</style>
</style> -->