From 54e2842b1b614d75781e27cd7bf037a0d4253dd2 Mon Sep 17 00:00:00 2001 From: sqj Date: Sat, 1 Nov 2025 20:15:43 +0800 Subject: [PATCH] =?UTF-8?q?=EF=BF=BDfeat:=20=E6=B7=BB=E5=8A=A0=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E6=AD=8C=E8=AF=8D=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=B9=B3=E5=8F=B0=E6=98=BE=E7=A4=BA=20fix=EF=BC=9A?= =?UTF-8?q?=E5=A4=A7=E5=B9=85=E4=BC=98=E5=8C=96=E6=89=93=E5=8C=85=E4=BD=93?= =?UTF-8?q?=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron-builder.yml | 8 +- electron.vite.config.ts | 9 +- package.json | 2 +- src/common/types/config.ts | 10 + src/main/events/index.ts | 64 +-- src/main/events/lyric.ts | 154 +++++ src/main/events/pluginNotice.ts | 2 +- src/main/index.ts | 132 ++++- src/main/logger/index.ts | 47 ++ src/main/services/ConfigManager.ts | 1 + .../plugin/manager/CeruMusicPluginHost.ts | 14 +- src/main/windows/index.ts | 45 ++ src/main/windows/lyric-window.ts | 92 +++ src/preload/index.ts | 4 +- src/renderer/components.d.ts | 3 + src/renderer/src/App.vue | 9 + src/renderer/src/assets/icons/lyricOpen.svg | 3 + src/renderer/src/assets/main.css | 4 + .../src/components/Music/SongVirtualList.vue | 11 + src/renderer/src/components/Play/FullPlay.vue | 79 ++- .../src/components/Play/PlayMusic.vue | 101 +++- .../components/Settings/DesktopLyricStyle.vue | 233 ++++++++ src/renderer/src/views/settings/index.vue | 5 + src/web/lyric.html | 541 ++++++++++++++++++ 24 files changed, 1492 insertions(+), 81 deletions(-) create mode 100644 src/common/types/config.ts create mode 100644 src/main/events/lyric.ts create mode 100644 src/main/logger/index.ts create mode 100644 src/main/windows/index.ts create mode 100644 src/main/windows/lyric-window.ts create mode 100644 src/renderer/src/assets/icons/lyricOpen.svg create mode 100644 src/renderer/src/components/Settings/DesktopLyricStyle.vue create mode 100644 src/web/lyric.html diff --git a/electron-builder.yml b/electron-builder.yml index 6ea8fd9..0b2b46a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -6,8 +6,12 @@ asar: true files: - '!**/.vscode/*' - '!src/*' + - '!website/*' + - '!scripts/*' + - '!assets/*' + - '!docs/*' - '!electron.vite.config.{js,ts,mjs,cjs}' - - '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' + - '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md,.idea,.kiro,.codebuddy}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' asarUnpack: @@ -81,4 +85,4 @@ publish: provider: generic url: https://update.ceru.shiqianjiang.cn electronDownload: - mirror: https://npmmirror.com/mirrors/electron/ + mirror: https://npmmirror.com/mirrors/electron/ \ No newline at end of file diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 8ed5dba..5e169da 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,7 +8,6 @@ import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver' import wasm from 'vite-plugin-wasm' import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' import topLevelAwait from 'vite-plugin-top-level-await' - export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], @@ -16,6 +15,14 @@ export default defineConfig({ alias: { '@common': resolve('src/common') } + }, + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/main/index.ts'), + lyric: resolve(__dirname, 'src/web/lyric.html') + } + } } }, preload: { diff --git a/package.json b/package.json index 9b9472f..b217b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ceru-music", - "version": "1.4.6", + "version": "1.4.7", "description": "一款简洁优雅的音乐播放器", "main": "./out/main/index.js", "author": "sqj,wldss,star", diff --git a/src/common/types/config.ts b/src/common/types/config.ts new file mode 100644 index 0000000..3624cd1 --- /dev/null +++ b/src/common/types/config.ts @@ -0,0 +1,10 @@ +export interface lyricConfig { + fontSize: number + mainColor: string + shadowColor: string + // 窗口位置 + x?: number + y?: number + width?: number + height?: number +} diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 7744e42..38d44b7 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -1,9 +1,9 @@ import InitPluginService from './plugins' import '../services/musicSdk/index' import aiEvents from '../events/ai' -import { app, powerSaveBlocker, Menu } from 'electron' +import { app, powerSaveBlocker } from 'electron' import path from 'node:path' -import { type BrowserWindow, Tray, ipcMain } from 'electron' +import { type BrowserWindow, ipcMain } from 'electron' export default function InitEventServices(mainWindow: BrowserWindow) { InitPluginService() aiEvents(mainWindow) @@ -12,58 +12,12 @@ export default function InitEventServices(mainWindow: BrowserWindow) { function basisEvent(mainWindow: BrowserWindow) { let psbId: number | null = null - let tray: Tray | null = null + // 复用主进程创建的托盘 + let tray: any = (global as any).__ceru_tray__ || null let isQuitting = false - function createTray(): void { - // 创建系统托盘 - const trayIconPath = path.join(__dirname, '../../resources/logo.png') - tray = new Tray(trayIconPath) + // 托盘菜单与图标由主进程统一创建,这里不再重复创建 + // 播放/暂停由主进程托盘菜单触发 'music-control' 事件 - // 创建托盘菜单 - const contextMenu = Menu.buildFromTemplate([ - { - label: '显示窗口', - click: () => { - if (mainWindow) { - mainWindow.show() - mainWindow.focus() - } - } - }, - { - label: '播放/暂停', - click: () => { - // 这里可以添加播放控制逻辑 - console.log('music-control') - mainWindow?.webContents.send('music-control') - } - }, - { type: 'separator' }, - { - label: '退出', - click: () => { - isQuitting = true - app.quit() - } - } - ]) - - tray.setContextMenu(contextMenu) - tray.setToolTip('Ceru Music') - - // 单击托盘图标显示窗口 - tray.on('click', () => { - if (mainWindow) { - if (mainWindow.isVisible()) { - mainWindow.hide() - } else { - mainWindow.show() - mainWindow.focus() - } - } - }) - } - createTray() // 应用退出前的清理 app.on('before-quit', () => { isQuitting = true @@ -93,7 +47,8 @@ function basisEvent(mainWindow: BrowserWindow) { // 进入 Mini 模式:隐藏窗口到系统托盘 mainWindow.hide() // 显示托盘通知(可选) - if (tray) { + tray = (global as any).__ceru_tray__ || tray + if (tray && tray.displayBalloon) { tray.displayBalloon({ title: '澜音 Music', content: '已最小化到系统托盘啦,点击托盘图标可重新打开~' @@ -119,7 +74,8 @@ function basisEvent(mainWindow: BrowserWindow) { mainWindow?.hide() // 显示托盘通知 - if (tray) { + tray = (global as any).__ceru_tray__ || tray + if (tray && tray.displayBalloon) { tray.displayBalloon({ title: 'Ceru Music', content: '已最小化到系统托盘啦,点击托盘图标可重新打开~' diff --git a/src/main/events/lyric.ts b/src/main/events/lyric.ts new file mode 100644 index 0000000..766ff3d --- /dev/null +++ b/src/main/events/lyric.ts @@ -0,0 +1,154 @@ +import { BrowserWindow, ipcMain, screen } from 'electron' +import { isAbsolute, relative, resolve } from 'path' +import { lyricConfig } from '@common/types/config' +import { configManager } from '../services/ConfigManager' + +import lyricWindow from '../windows/lyric-window' + +const lyricStore = { + get: () => + configManager.get('lyric', { + fontSize: 30, + mainColor: '#73BCFC', + shadowColor: 'rgba(255, 255, 255, 0.5)', + x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400, + y: screen.getPrimaryDisplay().workAreaSize.height - 90, + width: 800, + height: 180 + }), + set: (value: lyricConfig) => configManager.set('lyric', value) +} + +/** + * 歌词相关 IPC + */ +const initLyricIpc = (mainWin?: BrowserWindow | null): void => { + // const mainWin = mainWindow.getWin() + const lyricWin = lyricWindow.getWin() + + // 切换桌面歌词 + ipcMain.on('change-desktop-lyric', (_event, val: boolean) => { + if (val) { + lyricWin?.show() + lyricWin?.setAlwaysOnTop(true, 'screen-saver') + } else lyricWin?.hide() + }) + ipcMain.on('win-show', () => { + mainWin?.show() + }) + // 音乐名称更改 + ipcMain.on('play-song-change', (_, title) => { + if (!title) return + lyricWin?.webContents.send('play-song-change', title) + }) + + // 音乐歌词更改 + ipcMain.on('play-lyric-change', (_, lyricData) => { + if (!lyricData) return + lyricWin?.webContents.send('play-lyric-change', lyricData) + }) + + // 播放状态更改(播放/暂停) + ipcMain.on('play-status-change', (_, status: boolean) => { + lyricWin?.webContents.send('play-status-change', status) + }) + + // 获取窗口位置 + ipcMain.handle('get-window-bounds', () => { + return lyricWin?.getBounds() + }) + // 同步获取窗口位置(回退) + ipcMain.on('get-window-bounds-sync', (event) => { + event.returnValue = lyricWin?.getBounds() + }) + + // 获取屏幕尺寸 + ipcMain.handle('get-screen-size', () => { + const { width, height } = screen.getPrimaryDisplay().workAreaSize + return { width, height } + }) + // 同步获取屏幕尺寸(回退) + ipcMain.on('get-screen-size-sync', (event) => { + const { width, height } = screen.getPrimaryDisplay().workAreaSize + event.returnValue = { width, height } + }) + + // 移动窗口 + ipcMain.on('move-window', (_, x, y, width, height) => { + lyricWin?.setBounds({ x, y, width, height }) + // 保存配置 + lyricStore.set({ ...lyricStore.get(), x, y, width, height }) + // 保持置顶 + lyricWin?.setAlwaysOnTop(true, 'screen-saver') + }) + + // 更新高度 + ipcMain.on('update-window-height', (_, height) => { + if (!lyricWin) return + const { width } = lyricWin.getBounds() + + // 更新窗口高度 + lyricWin.setBounds({ width, height }) + }) + + // 获取配置 + ipcMain.handle('get-desktop-lyric-option', () => { + return lyricStore.get() + }) + // 同步获取配置(用于 invoke 不可用的回退) + ipcMain.on('get-desktop-lyric-option-sync', (event) => { + event.returnValue = lyricStore.get() + }) + + // 保存配置 + ipcMain.on('set-desktop-lyric-option', (_, option, callback: boolean = false) => { + lyricStore.set(option) + // 触发窗口更新 + if (callback && lyricWin) { + lyricWin.webContents.send('desktop-lyric-option-change', option) + } + mainWin?.webContents.send('desktop-lyric-option-change', option) + }) + + // 发送主程序事件 + ipcMain.on('send-main-event', (_, name, val) => { + mainWin?.webContents.send(name, val) + }) + + // 关闭桌面歌词 + ipcMain.on('closeDesktopLyric', () => { + lyricWin?.hide() + mainWin?.webContents.send('closeDesktopLyric') + }) + + // 锁定/解锁桌面歌词 + let lyricLockState = false + ipcMain.on('toogleDesktopLyricLock', (_, isLock: boolean) => { + if (!lyricWin) return + lyricLockState = !!isLock + // 是否穿透 + if (lyricLockState) { + lyricWin.setIgnoreMouseEvents(true, { forward: true }) + } else { + lyricWin.setIgnoreMouseEvents(false) + } + // 广播到桌面歌词窗口与主窗口,保持两端状态一致 + lyricWin.webContents.send('toogleDesktopLyricLock', lyricLockState) + mainWin?.webContents.send('toogleDesktopLyricLock', lyricLockState) + }) + + // 查询当前桌面歌词锁定状态 + ipcMain.handle('get-lyric-lock-state', () => lyricLockState) + + // 检查是否是子文件夹 + ipcMain.handle('check-if-subfolder', (_, localFilesPath: string[], selectedDir: string) => { + const resolvedSelectedDir = resolve(selectedDir) + const allPaths = localFilesPath.map((p) => resolve(p)) + return allPaths.some((existingPath) => { + const relativePath = relative(existingPath, resolvedSelectedDir) + return relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath) + }) + }) +} + +export default initLyricIpc diff --git a/src/main/events/pluginNotice.ts b/src/main/events/pluginNotice.ts index 3709280..783abcf 100644 --- a/src/main/events/pluginNotice.ts +++ b/src/main/events/pluginNotice.ts @@ -97,7 +97,7 @@ function getDefaultMessage(type: string, data: any, pluginName: string): string */ export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: string): void { try { - console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data) + // console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data) // 获取主窗口实例 const mainWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) diff --git a/src/main/index.ts b/src/main/index.ts index afdbc15..531f653 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,14 @@ -import { app, shell, BrowserWindow, ipcMain, screen, Rectangle, Display } from 'electron' +import { + app, + shell, + BrowserWindow, + ipcMain, + screen, + Rectangle, + Display, + Tray, + Menu +} from 'electron' import { configManager } from './services/ConfigManager' import { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' @@ -6,10 +16,13 @@ import icon from '../../resources/logo.png?asset' import path from 'node:path' import InitEventServices from './events' +import lyricWindow from './windows/lyric-window' + import './events/musicCache' import './events/songList' import './events/directorySettings' import './events/pluginNotice' +import initLyricIpc from './events/lyric' // 获取单实例锁 const gotTheLock = app.requestSingleInstanceLock() @@ -30,6 +43,112 @@ if (!gotTheLock) { } let mainWindow: BrowserWindow | null = null +let tray: Tray | null = null +let trayLyricLocked = false + +function updateTrayMenu() { + const lyricWin = lyricWindow.getWin() + const isVisible = !!lyricWin && lyricWin.isVisible() + const toggleLyricLabel = isVisible ? '隐藏桌面歌词' : '显示桌面歌词' + const toggleLockLabel = trayLyricLocked ? '解锁桌面歌词' : '锁定桌面歌词' + + const contextMenu = Menu.buildFromTemplate([ + { + label: toggleLyricLabel, + click: () => { + const target = !isVisible + ipcMain.emit('change-desktop-lyric', null, target) + } + }, + { + label: toggleLockLabel, + click: () => { + const next = !trayLyricLocked + ipcMain.emit('toogleDesktopLyricLock', null, next) + } + }, + { type: 'separator' }, + { + label: '显示主窗口', + click: () => { + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + } + }, + { + label: '播放/暂停', + click: () => { + mainWindow?.webContents.send('music-control') + } + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + app.quit() + } + } + ]) + tray?.setContextMenu(contextMenu) +} + +function setupTray() { + // 全局单例防重复(热重载/多次执行保护) + const g: any = global as any + if (g.__ceru_tray__) { + try { + g.__ceru_tray__.destroy() + } catch {} + g.__ceru_tray__ = null + } + if (tray) { + try { + tray.destroy() + } catch {} + tray = null + } + + const iconPath = path.join(__dirname, '../../resources/logo.ico') + tray = new Tray(iconPath) + tray.setToolTip('Ceru Music') + updateTrayMenu() + + // 左键单击切换主窗口显示 + tray.on('click', () => { + if (!mainWindow) return + if (mainWindow.isVisible()) { + mainWindow.hide() + } else { + mainWindow.show() + mainWindow.focus() + } + }) + + // 防重复注册 IPC 监听(仅注册一次) + if (!g.__ceru_tray_ipc_bound__) { + ipcMain.on('toogleDesktopLyricLock', (_e, isLock: boolean) => { + trayLyricLocked = !!isLock + updateTrayMenu() + }) + ipcMain.on('change-desktop-lyric', () => { + updateTrayMenu() + }) + g.__ceru_tray_ipc_bound__ = true + } + + // 记录全局托盘句柄 + g.__ceru_tray__ = tray + + app.once('before-quit', () => { + try { + tray?.destroy() + } catch {} + tray = null + g.__ceru_tray__ = null + }) +} /** * 根据窗口当前所在的显示器,动态更新窗口的最大尺寸限制。 @@ -64,9 +183,6 @@ function createWindow(): void { height: 750, minWidth: 1100, minHeight: 670, - // ⚠️ 关键修改 1: 移除 maxWidth 和 maxHeight 的硬编码限制 - // maxWidth: screenWidth, - // maxHeight: screenHeight, show: false, center: !savedBounds, // 如果有保存的位置,则不居中 autoHideMenuBar: true, @@ -146,6 +262,7 @@ function createWindow(): void { return { action: 'deny' } }) InitEventServices(mainWindow) + // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. if (is.dev && process.env['ELECTRON_RENDERER_URL']) { @@ -179,6 +296,10 @@ app.whenReady().then(() => { }) createWindow() + lyricWindow.create() + initLyricIpc(mainWindow) + // 仅在主进程初始化一次托盘 + setupTray() // 注册自动更新事件 registerAutoUpdateEvents() @@ -204,8 +325,7 @@ app.whenReady().then(() => { // 当所有窗口关闭时不退出应用,因为我们有系统托盘 app.on('window-all-closed', () => { - // 在 macOS 上,应用通常会保持活跃状态 - // 在其他平台上,我们也保持应用运行,因为有系统托盘 + // 保持应用常驻,通过系统托盘管理 }) // In this file you can include the rest of your app's specific main process diff --git a/src/main/logger/index.ts b/src/main/logger/index.ts new file mode 100644 index 0000000..c9eb412 --- /dev/null +++ b/src/main/logger/index.ts @@ -0,0 +1,47 @@ +// 日志输出 +import { existsSync, mkdirSync } from 'fs' +import { join } from 'path' +import { app } from 'electron' +import log from 'electron-log' + +// 日志文件路径 +const logDir = join(app.getPath('logs')) +// 是否存在日志目录 +if (!existsSync(logDir)) mkdirSync(logDir) + +// 获取日期 - YYYY-MM-DD +const dateString = new Date().toISOString().slice(0, 10) +const logFilePath = join(logDir, `${dateString}.log`) +console.log(logFilePath, '546444444444444444444444444444444444') + +// 配置日志系统 +log.transports.console.useStyles = true // 颜色输出 +log.transports.file.level = 'info' // 仅记录 info 及以上级别 +log.transports.file.resolvePathFn = (): string => logFilePath // 日志文件路径 +log.transports.file.maxSize = 2 * 1024 * 1024 // 文件最大 2MB + +// 日志格式化 +// log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}] [{level}] [{scope}] {text}"; + +// 绑定默认事件 +const defaultLog = log.scope('default') +console.log = defaultLog.log +console.info = defaultLog.info +console.warn = defaultLog.warn +console.error = defaultLog.error + +// 分作用域导出 +export { defaultLog } +export const ipcLog = log.scope('ipc') +export const trayLog = log.scope('tray') +export const thumbarLog = log.scope('thumbar') +export const storeLog = log.scope('store') +export const updateLog = log.scope('update') +export const systemLog = log.scope('system') +export const configLog = log.scope('config') +export const windowsLog = log.scope('windows') +export const processLog = log.scope('process') +export const preloadLog = log.scope('preload') +export const rendererLog = log.scope('renderer') +export const shortcutLog = log.scope('shortcut') +export const serverLog = log.scope('server') diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index bb680ca..971809c 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -60,6 +60,7 @@ export class ConfigManager { // 设置配置项 public set(key: string, value: T): void { this.config[key] = value + this.saveConfig() } // 删除配置项 diff --git a/src/main/services/plugin/manager/CeruMusicPluginHost.ts b/src/main/services/plugin/manager/CeruMusicPluginHost.ts index 71581d2..4e72fcb 100644 --- a/src/main/services/plugin/manager/CeruMusicPluginHost.ts +++ b/src/main/services/plugin/manager/CeruMusicPluginHost.ts @@ -493,7 +493,7 @@ class CeruMusicPluginHost { }, timeout) try { - console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`) + // console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`) const fetchOptions = { method: 'GET', @@ -504,7 +504,7 @@ class CeruMusicPluginHost { const response = await fetch(url, fetchOptions) clearTimeout(timeoutId) - console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`) + // console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`) const body = await this._parseResponseBody(response) const headers = this._extractHeaders(response) @@ -515,11 +515,11 @@ class CeruMusicPluginHost { headers } - console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, { - url, - status: response.status, - bodyType: typeof body - }) + // console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, { + // url, + // status: response.status, + // bodyType: typeof body + // }) return result } catch (error: any) { diff --git a/src/main/windows/index.ts b/src/main/windows/index.ts new file mode 100644 index 0000000..1acb72c --- /dev/null +++ b/src/main/windows/index.ts @@ -0,0 +1,45 @@ +import { BrowserWindow, BrowserWindowConstructorOptions, app } from 'electron' +import { windowsLog } from '../logger' +import { join } from 'path' +import icon from '../../../resources/logo.png?asset' + +export const createWindow = ( + options: BrowserWindowConstructorOptions = {} +): BrowserWindow | null => { + try { + const defaultOptions: BrowserWindowConstructorOptions = { + title: app.getName(), + width: 1280, + height: 720, + frame: false, // 创建后是否显示窗口 + center: true, // 窗口居中 + icon, // 窗口图标 + autoHideMenuBar: true, // 隐藏菜单栏 + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + // 禁用渲染器沙盒 + sandbox: false, + // 禁用同源策略 + webSecurity: false, + // 允许 HTTP + allowRunningInsecureContent: true, + // 禁用拼写检查 + spellcheck: false, + // 启用 Node.js + nodeIntegration: true, + nodeIntegrationInWorker: true, + // 关闭上下文隔离,确保在窗口中注入 window.electron + contextIsolation: false, + backgroundThrottling: false + } + } + // 合并参数 + options = Object.assign(defaultOptions, options) + // 创建窗口 + const win = new BrowserWindow(options) + return win + } catch (error) { + windowsLog.error(error) + return null + } +} diff --git a/src/main/windows/lyric-window.ts b/src/main/windows/lyric-window.ts new file mode 100644 index 0000000..597bea9 --- /dev/null +++ b/src/main/windows/lyric-window.ts @@ -0,0 +1,92 @@ +import { BrowserWindow, screen } from 'electron' +import { createWindow } from './index' +import { configManager } from '../services/ConfigManager' +import { join } from 'path' +import { lyricConfig } from '@common/types/config' + +const lyricStore = { + get: () => + configManager.get('lyric', { + fontSize: 30, + mainColor: '#73BCFC', + shadowColor: 'rgba(255, 255, 255, 0.5)', + x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400, + y: screen.getPrimaryDisplay().workAreaSize.height - 90, + width: 800, + height: 180 + }), + set: (value: lyricConfig) => configManager.set('lyric', value) +} + +class LyricWindow { + private win: BrowserWindow | null = null + constructor() {} + /** + * 主窗口事件 + * @returns void + */ + private event(): void { + if (!this.win) return + // 歌词窗口缩放 + this.win?.on('resized', () => { + const bounds = this.win?.getBounds() + if (bounds) { + const { width, height } = bounds + console.log('歌词窗口缩放:', width, height); + + lyricStore.set({ + ...lyricStore.get(), + width, + height + }) + } + }) + } + /** + * 创建主窗口 + * @returns BrowserWindow | null + */ + create(): BrowserWindow | null { + const { width, height, x, y } = lyricStore.get() + this.win = createWindow({ + width: width || 800, + height: height || 180, + minWidth: 440, + minHeight: 120, + maxWidth: 1600, + maxHeight: 300, + show: false, + // 窗口位置 + x, + y, + transparent: true, + backgroundColor: 'rgba(0, 0, 0, 0)', + alwaysOnTop: true, + resizable: true, + movable: true, + // 不在任务栏显示 + skipTaskbar: true, + // 窗口不能最小化 + minimizable: false, + // 窗口不能最大化 + maximizable: false, + // 窗口不能进入全屏状态 + fullscreenable: false + }) + if (!this.win) return null + // 加载地址(开发环境用项目根目录,生产用打包后的相对路径) + this.win.loadFile(join(__dirname, '../main/src/web/lyric.html')) + // 窗口事件 + this.event() + return this.win + } + /** + * 获取窗口 + * @returns BrowserWindow | null + */ + getWin(): BrowserWindow | null { + return this.win + } +} + +export default new LyricWindow() diff --git a/src/preload/index.ts b/src/preload/index.ts index bf9688f..96cbc83 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -207,14 +207,14 @@ const api = { // just add to the DOM global. if (process.contextIsolated) { try { - contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('electron', { ...electronAPI, ipcRenderer }) contextBridge.exposeInMainWorld('api', api) } catch (error) { console.error(error) } } else { // @ts-ignore (define in dts) - window.electron = electronAPI + window.electron = { ...electronAPI, ipcRenderer } // @ts-ignore (define in dts) window.api = api } diff --git a/src/renderer/components.d.ts b/src/renderer/components.d.ts index 30a2da0..c0680aa 100644 --- a/src/renderer/components.d.ts +++ b/src/renderer/components.d.ts @@ -12,6 +12,7 @@ declare module 'vue' { AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default'] ContextMenu: typeof import('./src/components/ContextMenu/ContextMenu.vue')['default'] Demo: typeof import('./src/components/ContextMenu/demo.vue')['default'] + DesktopLyricStyle: typeof import('./src/components/Settings/DesktopLyricStyle.vue')['default'] DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default'] FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default'] FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default'] @@ -40,6 +41,7 @@ declare module 'vue' { TButton: typeof import('tdesign-vue-next')['Button'] TCard: typeof import('tdesign-vue-next')['Card'] TCheckbox: typeof import('tdesign-vue-next')['Checkbox'] + TColorPicker: typeof import('tdesign-vue-next')['ColorPicker'] TContent: typeof import('tdesign-vue-next')['Content'] TDialog: typeof import('tdesign-vue-next')['Dialog'] TDivider: typeof import('tdesign-vue-next')['Divider'] @@ -50,6 +52,7 @@ declare module 'vue' { TIcon: typeof import('tdesign-vue-next')['Icon'] TImage: typeof import('tdesign-vue-next')['Image'] TInput: typeof import('tdesign-vue-next')['Input'] + TInputNumber: typeof import('tdesign-vue-next')['InputNumber'] TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default'] TLayout: typeof import('tdesign-vue-next')['Layout'] TLoading: typeof import('tdesign-vue-next')['Loading'] diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 4ef1281..cec6d45 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -31,6 +31,15 @@ onMounted(() => { syncNaiveTheme() window.addEventListener('theme-changed', () => syncNaiveTheme()) + // 全局监听来自主进程的播放控制事件,确保路由切换也可响应 + const forward = (name: string, val?: any) => { + window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } })) + } + window.electron?.ipcRenderer?.on?.('play', () => forward('play')) + window.electron?.ipcRenderer?.on?.('pause', () => forward('pause')) + window.electron?.ipcRenderer?.on?.('playPrev', () => forward('playPrev')) + window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext')) + // 应用启动后延迟3秒检查更新,避免影响启动速度 setTimeout(() => { checkForUpdates() diff --git a/src/renderer/src/assets/icons/lyricOpen.svg b/src/renderer/src/assets/icons/lyricOpen.svg new file mode 100644 index 0000000..d23e09b --- /dev/null +++ b/src/renderer/src/assets/icons/lyricOpen.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index cdc30e6..87437e9 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -234,6 +234,8 @@ --song-list-btn-bg-hover: var(--td-brand-color-light); --song-list-quality-bg: #fff7e6; --song-list-quality-color: #fa8c16; + --song-list-source-bg: #f3feff; + --song-list-source-color: #00d4e3; /* Search 页面专用变量 - 亮色主题 */ --search-bg: var(--theme-bg-tertiary); @@ -597,6 +599,8 @@ --song-list-btn-bg-hover: var(--td-brand-color-light); --song-list-quality-bg: #3a2a1a; --song-list-quality-color: #fa8c16; + --song-list-source-bg: #343939; + --song-list-source-color: #00eeff; /* Search 页面专用变量 - 暗色主题 */ --search-bg: var(--theme-bg-tertiary); diff --git a/src/renderer/src/components/Music/SongVirtualList.vue b/src/renderer/src/components/Music/SongVirtualList.vue index 874266d..4f213cc 100644 --- a/src/renderer/src/components/Music/SongVirtualList.vue +++ b/src/renderer/src/components/Music/SongVirtualList.vue @@ -44,6 +44,9 @@ {{ getQualityDisplayName(song.types[song.types.length - 1]) }} + + {{ song.source }} + {{ song.singer }} @@ -759,6 +762,14 @@ onUnmounted(() => { font-size: 10px; line-height: 1; } + .source-tag { + background: var(--song-list-source-bg); + color: var(--song-list-source-color); + padding: 1px 4px; + border-radius: 2px; + font-size: 10px; + line-height: 1; + } } } } diff --git a/src/renderer/src/components/Play/FullPlay.vue b/src/renderer/src/components/Play/FullPlay.vue index 97ef80a..b5bd3a2 100644 --- a/src/renderer/src/components/Play/FullPlay.vue +++ b/src/renderer/src/components/Play/FullPlay.vue @@ -172,12 +172,14 @@ watch( props.songInfo && 'source' in props.songInfo ? (props.songInfo as any).source : 'kg' let parsedLyrics: LyricLine[] = [] - if (source === 'wy') { - // 网易云:优先尝试 TTML + if (source === 'wy' || source === 'tx') { + // 网易云 / QQ 音乐:优先尝试 TTML try { const res = await ( - await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`, { - signal: abort.signal + await fetch( + `https://amll-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${newId}`, + { + signal: abort.signal }) ).text() if (!active) return @@ -186,13 +188,13 @@ watch( } catch { // 回退到统一歌词 API const lyricData = await window.api.music.requestSdk('getLyric', { - source: 'wy', + source, songInfo: _.cloneDeep(toRaw(props.songInfo)) as any }) if (!active) return if (lyricData?.crlyric) { - parsedLyrics = parseYrc(lyricData.crlyric) + parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric) } else if (lyricData?.lyric) { parsedLyrics = parseLrc(lyricData.lyric) } @@ -227,6 +229,71 @@ watch( { immediate: true } ) +// 桌面歌词联动:构建歌词负载、计算当前行并通过 IPC 推送 +const buildLyricPayload = (lines: LyricLine[]) => + (lines || []).map((l) => ({ + content: (l.words || []).map((w) => w.word).join(''), + tran: l.translatedLyric || '' + })) + +const lastLyricIndex = ref(-1) +const computeLyricIndex = (timeMs: number, lines: LyricLine[]) => { + if (!lines || lines.length === 0) return -1 + const t = timeMs + const i = lines.findIndex((l) => t >= l.startTime && t < l.endTime) + if (i !== -1) return i + for (let j = lines.length - 1; j >= 0; j--) { + if (t >= lines[j].startTime) return j + } + return -1 +} + +// 歌词集合变化时,先推一次集合,index 为 -1(由窗口自行处理占位) +watch( + () => state.lyricLines, + (lines) => { + const payload = { index: -1, lyric: buildLyricPayload(lines) } + ;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', payload) + }, + { deep: true, immediate: true } +) + +// 当前时间变化时,计算当前行并推送 +watch( + () => state.currentTime, + (ms) => { + const idx = computeLyricIndex(ms, state.lyricLines) + if (idx !== lastLyricIndex.value) { + lastLyricIndex.value = idx + const payload = { index: idx, lyric: buildLyricPayload(state.lyricLines) } + ;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', payload) + } + } +) + +// 播放状态推送(用于窗口播放/暂停按钮联动) +watch( + () => Audio.value.isPlay, + (playing) => { + ;(window as any)?.electron?.ipcRenderer?.send?.('play-status-change', playing) + }, + { immediate: true } +) + +// 歌曲标题推送 +watch( + () => props.songInfo, + (info) => { + try { + const name = (info as any)?.name || '' + const artist = (info as any)?.singer || '' + const title = [name, artist].filter(Boolean).join(' - ') + if (title) (window as any)?.electron?.ipcRenderer?.send?.('play-song-change', title) + } catch {} + }, + { immediate: true, deep: true } +) + const bgRef = ref(undefined) const lyricPlayerRef = ref(undefined) diff --git a/src/renderer/src/components/Play/PlayMusic.vue b/src/renderer/src/components/Play/PlayMusic.vue index 85bd77f..04472a0 100644 --- a/src/renderer/src/components/Play/PlayMusic.vue +++ b/src/renderer/src/components/Play/PlayMusic.vue @@ -30,7 +30,7 @@ import { import mediaSessionController from '@renderer/utils/audio/useSmtc' import defaultCoverImg from '/default-cover.png' import { downloadSingleSong } from '@renderer/utils/audio/download' -import { HeartIcon, DownloadIcon } from 'tdesign-icons-vue-next' +import { HeartIcon, DownloadIcon, CheckIcon, LockOnIcon } from 'tdesign-icons-vue-next' import _ from 'lodash' import { songListAPI } from '@renderer/api/songList' @@ -69,6 +69,70 @@ watch( ) onMounted(() => refreshLikeState()) const showFullPlay = ref(false) +// 桌面歌词开关与锁定状态 +const desktopLyricOpen = ref(false) +const desktopLyricLocked = ref(false) + +// 桌面歌词按钮逻辑: +// - 若未打开:打开桌面歌词 +// - 若已打开且锁定:先解锁,不关闭 +// - 若已打开且未锁定:关闭桌面歌词 +const toggleDesktopLyric = async () => { + try { + if (!desktopLyricOpen.value) { + window.electron?.ipcRenderer?.send?.('change-desktop-lyric', true) + desktopLyricOpen.value = true + // 恢复最新锁定状态 + const lock = await window.electron?.ipcRenderer?.invoke?.('get-lyric-lock-state') + desktopLyricLocked.value = !!lock + return + } + // 已打开 + const lock = await window.electron?.ipcRenderer?.invoke?.('get-lyric-lock-state') + desktopLyricLocked.value = !!lock + if (desktopLyricLocked.value) { + // 先解锁,本次不关闭 + window.electron?.ipcRenderer?.send?.('toogleDesktopLyricLock', false) + desktopLyricLocked.value = false + return + } + // 未锁定则关闭 + window.electron?.ipcRenderer?.send?.('change-desktop-lyric', false) + desktopLyricOpen.value = false + } catch (e) { + console.error('切换桌面歌词失败:', e) + } +} + +// 监听来自主进程的锁定状态广播 +window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => { + desktopLyricLocked.value = !!lock +}) +// 监听主进程通知关闭桌面歌词 +window.electron?.ipcRenderer?.on?.('closeDesktopLyric', () => { + desktopLyricOpen.value = false + desktopLyricLocked.value = false +}) + +window.addEventListener('global-music-control', (e: any) => { + const name = e?.detail?.name + console.log(name); + switch (name) { + case 'play': + handlePlay() + break + case 'pause': + handlePause() + break + case 'playPrev': + playPrevious() + break + case 'playNext': + playNext() + break + } +}) + document.addEventListener('keydown', KeyEvent) // 处理最小化右键的事件 const removeMusicCtrlListener = window.api.onMusicCtrl(() => { @@ -1091,6 +1155,29 @@ watch(showFullPlay, (val) => { + + + + + + + + + + @@ -1444,6 +1531,7 @@ watch(showFullPlay, (val) => { display: flex; align-items: center; justify-content: center; + position: relative; .iconfont { font-size: 18px; @@ -1452,6 +1540,17 @@ watch(showFullPlay, (val) => { &:hover { color: v-bind(hoverColor); } + + &.lyric-btn .lyric-check, + &.lyric-btn .lyric-lock { + position: absolute; + right: -1px; + bottom: -1px; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 2px #fff; + color: v-bind(maincolor); + } } } } diff --git a/src/renderer/src/components/Settings/DesktopLyricStyle.vue b/src/renderer/src/components/Settings/DesktopLyricStyle.vue new file mode 100644 index 0000000..34b85f5 --- /dev/null +++ b/src/renderer/src/components/Settings/DesktopLyricStyle.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/src/renderer/src/views/settings/index.vue b/src/renderer/src/views/settings/index.vue index 79c3283..696de4e 100644 --- a/src/renderer/src/views/settings/index.vue +++ b/src/renderer/src/views/settings/index.vue @@ -18,6 +18,7 @@ import DirectorySettings from '@renderer/components/Settings/DirectorySettings.v import MusicCache from '@renderer/components/Settings/MusicCache.vue' import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettings.vue' import ThemeSelector from '@renderer/components/ThemeSelector.vue' +import DesktopLyricStyle from '@renderer/components/Settings/DesktopLyricStyle.vue' import Versions from '@renderer/components/Versions.vue' import { useAutoUpdate } from '@renderer/composables/useAutoUpdate' import { playSetting as usePlaySetting } from '@renderer/store/playSetting' @@ -419,6 +420,10 @@ const getTagOptionsStatus = () => {

应用主题色

+ +
+ +
diff --git a/src/web/lyric.html b/src/web/lyric.html new file mode 100644 index 0000000..1718a86 --- /dev/null +++ b/src/web/lyric.html @@ -0,0 +1,541 @@ + + + + + + + 澜音 - 桌面歌词 + + + + +
+
+ CeruMusic(澜音) + 未知艺术家 +
+
+
+ + + + + + + + + + +
+
+ + + +
+
+ + + +
+
+ + + +
+ + +
+ + + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ 该歌曲暂无歌词 + +
+ + +