�feat: 添加桌面歌词功能,列表平台显示 fix:大幅优化打包体积

This commit is contained in:
sqj
2025-11-01 20:15:43 +08:00
parent ce743e1b65
commit 54e2842b1b
24 changed files with 1492 additions and 81 deletions

View File

@@ -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:

View File

@@ -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: {

View File

@@ -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",

View File

@@ -0,0 +1,10 @@
export interface lyricConfig {
fontSize: number
mainColor: string
shadowColor: string
// 窗口位置
x?: number
y?: number
width?: number
height?: number
}

View File

@@ -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: '已最小化到系统托盘啦,点击托盘图标可重新打开~'

154
src/main/events/lyric.ts Normal file
View File

@@ -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<lyricConfig>('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<lyricConfig>('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

View File

@@ -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())

View File

@@ -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

47
src/main/logger/index.ts Normal file
View File

@@ -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')

View File

@@ -60,6 +60,7 @@ export class ConfigManager {
// 设置配置项
public set<T>(key: string, value: T): void {
this.config[key] = value
this.saveConfig()
}
// 删除配置项

View File

@@ -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) {

45
src/main/windows/index.ts Normal file
View File

@@ -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
}
}

View File

@@ -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<lyricConfig>('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<lyricConfig>('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()

View File

@@ -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
}

View File

@@ -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']

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
<svg class="lyrics-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M192 240a112 112 0 0 1 111.616 102.784l0.384 9.216V832a16 16 0 0 0 12.352 15.552L320 848h66.816a48 48 0 0 1 6.528 95.552l-6.528 0.448H320a112 112 0 0 1-111.616-102.784L208 832V352a16 16 0 0 0-12.352-15.552L192 336H128a48 48 0 0 1-6.528-95.552L128 240h64z m640-157.568a112 112 0 0 1 111.616 102.848l0.384 9.152V832a112 112 0 0 1-102.784 111.616L832 944h-67.84a48 48 0 0 1-6.464-95.552l6.464-0.448H832a16 16 0 0 0 15.552-12.352L848 832V194.432a16 16 0 0 0-12.352-15.552L832 178.432H480a48 48 0 0 1-6.528-95.552l6.528-0.448H832z m-160 315.136c61.824 0 112 50.112 112 112v147.648a112 112 0 0 1-112 112h-128a112 112 0 0 1-112-112V509.568c0-61.888 50.176-112 112-112z m0 96h-128a16 16 0 0 0-16 16v147.648c0 8.832 7.168 16 16 16h128a16 16 0 0 0 16-16V509.568a16 16 0 0 0-16-16z m64-253.568a48 48 0 0 1 6.528 95.552l-6.528 0.448h-256a48 48 0 0 1-6.528-95.552L480 240h256zM256 82.432a48 48 0 0 1 6.528 95.616L256 178.432H128a48 48 0 0 1-6.528-95.552L128 82.432h128z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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);

View File

@@ -44,6 +44,9 @@
<span v-if="song.types && song.types.length > 0" class="quality-tag">
{{ getQualityDisplayName(song.types[song.types.length - 1]) }}
</span>
<span v-if="song.source" class="source-tag">
{{ song.source }}
</span>
{{ song.singer }}
</div>
</div>
@@ -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;
}
}
}
}

View File

@@ -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<BackgroundRenderRef | undefined>(undefined)
const lyricPlayerRef = ref<LyricPlayerRef | undefined>(undefined)

View File

@@ -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) => {
</transition>
</div>
<!-- 桌面歌词开关按钮 -->
<t-tooltip
:content="
desktopLyricOpen ? (desktopLyricLocked ? '解锁歌词' : '关闭桌面歌词') : '打开桌面歌词'
"
>
<t-button
class="control-btn lyric-btn"
shape="circle"
variant="text"
:disabled="!songInfo.songmid"
@click.stop="toggleDesktopLyric"
>
<SvgIcon name="lyricOpen" size="18"></SvgIcon>
<transition name="fade" mode="out-in">
<template v-if="desktopLyricOpen">
<LockOnIcon v-if="desktopLyricLocked" key="lock" class="lyric-lock" size="8" />
<CheckIcon v-else key="check" class="lyric-check" size="8" />
</template>
</transition>
</t-button>
</t-tooltip>
<!-- 播放列表按钮 -->
<t-tooltip content="播放列表">
<n-badge :value="list.length" :max="99" color="#bbb">
@@ -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);
}
}
}
}

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface LyricOption {
fontSize: number
mainColor: string
shadowColor: string
x: number
y: number
width: number
height: number
}
const loading = ref(false)
const saving = ref(false)
const option = ref<LyricOption>({
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: 0,
y: 0,
width: 800,
height: 180
})
const shadowRgb = ref<{ r: number; g: number; b: number }>({ r: 255, g: 255, b: 255 })
const mainHex = ref<string>('#73BCFC')
const shadowColorStr = ref<string>('rgba(255, 255, 255, 0.5')
const parseColorToRgb = (input: string) => {
const rgbaMatch = input?.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i)
if (rgbaMatch) {
return { r: Number(rgbaMatch[1]), g: Number(rgbaMatch[2]), b: Number(rgbaMatch[3]) }
}
const hexMatch = input?.match(/^#([0-9a-f]{6})$/i)
if (hexMatch) {
const hex = hexMatch[1]
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
}
}
return { r: 255, g: 255, b: 255 }
}
const hexToRgb = (hex: string) => {
const m = hex?.match(/^#([0-9a-f]{6})$/i)
if (!m) return { r: 255, g: 255, b: 255 }
const h = m[1]
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16)
}
}
const rgbToHex = (r: number, g: number, b: number) =>
`#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`
const buildShadowRgba = () =>
`rgba(${shadowRgb.value.r}, ${shadowRgb.value.g}, ${shadowRgb.value.b}, 0.5)`
const onMainColorChange = (val: string) => {
mainHex.value = val
option.value.mainColor = val
}
const onShadowColorChange = (val: string) => {
shadowColorStr.value = val
option.value.shadowColor = val
}
const original = ref<LyricOption | null>(null)
const loadOption = async () => {
loading.value = true
try {
const res = await window.electron.ipcRenderer.invoke('get-desktop-lyric-option')
if (res) {
option.value = { ...option.value, ...res }
original.value = { ...option.value }
mainHex.value = option.value.mainColor || '#73BCFC'
shadowColorStr.value = option.value.shadowColor || 'rgba(255,255,255,0.5)'
shadowRgb.value = parseColorToRgb(shadowColorStr.value)
}
} catch (e) {
console.warn('加载桌面歌词配置失败:', e)
} finally {
loading.value = false
}
}
const applyOption = () => {
saving.value = true
try {
// 传入 callback=true 让桌面歌词窗口即时更新
const payload = { ...option.value, shadowColor: shadowColorStr.value }
window.electron.ipcRenderer.send('set-desktop-lyric-option', payload, true)
} finally {
setTimeout(() => (saving.value = false), 200)
}
}
const resetOption = () => {
if (!original.value) return
option.value = { ...original.value }
applyOption()
}
const toggleDesktopLyric = (enabled: boolean) => {
window.electron.ipcRenderer.send('change-desktop-lyric', enabled)
}
onMounted(() => {
loadOption()
})
</script>
<template>
<div class="lyric-style">
<div class="header">
<h3>桌面歌词样式</h3>
<p>自定义桌面歌词的字体大小颜色与阴影效果并可预览与即时应用</p>
</div>
<div class="controls">
<div class="row">
<div class="field">
<label>字体大小(px)</label>
<t-input-number v-model="option.fontSize" :min="12" :max="96" :step="1" />
</div>
<div class="field">
<label>主颜色</label>
<t-color-picker
v-model="mainHex"
:color-modes="['monochrome']"
format="HEX"
@change="onMainColorChange"
/>
</div>
<div class="field">
<label>阴影颜色</label>
<t-color-picker
v-model="shadowColorStr"
:color-modes="['monochrome']"
format="RGBA"
:enable-alpha="true"
@change="onShadowColorChange"
/>
</div>
</div>
<div class="row">
<div class="field">
<label>宽度</label>
<t-input-number v-model="option.width" :min="300" :max="1600" :step="10" />
</div>
<div class="field">
<label>高度</label>
<t-input-number v-model="option.height" :min="100" :max="600" :step="10" />
</div>
</div>
<div class="actions">
<t-button :loading="loading" theme="default" variant="outline" @click="loadOption"
>刷新</t-button
>
<t-button :loading="saving" theme="primary" @click="applyOption">应用到桌面歌词</t-button>
<t-button theme="default" @click="resetOption">还原</t-button>
<t-switch @change="toggleDesktopLyric($event as boolean)">显示桌面歌词</t-switch>
</div>
</div>
<div class="preview">
<div
class="preview-lyric"
:style="{
fontSize: option.fontSize + 'px',
color: mainHex,
textShadow: `0 0 6px ${shadowColorStr}`
}"
>
这是桌面歌词预览
</div>
</div>
</div>
</template>
<style scoped>
.lyric-style {
display: flex;
flex-direction: column;
gap: 16px;
}
.header h3 {
margin: 0 0 6px 0;
}
.controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.color-input {
width: 48px;
height: 32px;
padding: 0;
border: 1px solid var(--td-border-level-1-color);
border-radius: var(--td-radius-small);
background: var(--td-bg-color-container);
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview {
padding: 16px;
border: 1px dashed var(--td-border-level-1-color);
border-radius: var(--td-radius-medium);
background: var(--settings-preview-bg);
}
.preview-lyric {
text-align: center;
font-weight: 700;
}
</style>

View File

@@ -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 = () => {
<h3>应用主题色</h3>
<ThemeSelector />
</div>
<div class="setting-group">
<DesktopLyricStyle />
</div>
</div>
<!-- AI 功能设置 -->

541
src/web/lyric.html Normal file
View File

@@ -0,0 +1,541 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>澜音 - 桌面歌词</title>
<style>
* {
margin: 0;
padding: 0;
user-select: none;
box-sizing: border-box;
-webkit-user-drag: none;
}
:root {
--font-size: 30;
--main-color: #73BCFC;
--shadow-color: rgba(255, 255, 255, 0.5);
}
body {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding: 12px;
cursor: pointer;
color: var(--main-color);
overflow: hidden;
transition: opacity 0.3s;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
z-index: 0;
opacity: 0;
cursor: move;
transition: opacity 0.3s;
}
&:hover {
&::after {
opacity: 1;
}
header {
.meta {
opacity: 0;
}
.tools {
opacity: 1;
}
}
}
&.lock-lyric {
cursor: none;
/* 鼠标穿透 */
pointer-events: none;
* {
pointer-events: none;
}
&::after {
opacity: 0;
}
&:hover {
opacity: 0;
}
header {
.meta {
opacity: 1;
}
.tools {
opacity: 0;
}
}
}
}
header {
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
.meta {
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
opacity: 0.9;
transition: opacity 0.3s;
}
.tools {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
opacity: 0;
transition: opacity 0.3s;
gap: 8px;
.item {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
cursor: pointer;
transition:
transform 0.3s,
background-color 0.3s;
&.hidden {
display: none;
}
&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
&:active {
transform: scale(0.95);
}
svg {
width: 24px;
height: 24px;
}
}
}
#song-artist {
margin-top: 4px;
font-size: 12px;
opacity: 0.8;
}
}
main {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 12px;
margin: 12px;
z-index: 1;
max-width: 100%;
pointer-events: auto;
#lyric-text {
font-size: calc(var(--font-size) * 1px);
font-weight: bold;
}
#lyric-tran {
font-size: calc(var(--font-size) * 1px - 5px);
margin-top: 8px;
opacity: 0.6;
}
}
span {
padding: 0 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 0 4px var(--shadow-color);
transition: opacity 0.3s;
/* animation: 15s wordsLoop linear infinite normal; */
}
@keyframes wordsLoop {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(-100%);
}
}
</style>
</head>
<body>
<header>
<div class="meta">
<span id="song-name">CeruMusic澜音</span>
<span id="song-artist">未知艺术家</span>
</div>
<div class="tools" id="tools">
<div id="show-app" class="item" title="打开应用">
<svg
width="1200"
height="1200"
viewBox="0 0 1200 1200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="1200" height="1200" rx="379" fill="white" />
<path
d="M957.362 204.197C728.535 260.695 763.039 192.264 634.41 175.368C451.817 151.501 504.125 315.925 504.125 315.925L630.545 673.497C591.211 654.805 544.287 643.928 494.188 643.928C353.275 643.928 239 729.467 239 834.964C239 940.567 353.137 1026 494.188 1026C635.1 1026 749.375 940.461 749.375 834.964C749.375 832.218 749.237 829.473 749.099 826.727C749.513 825.988 749.789 825.143 750.065 824.087C757.932 789.449 634.272 348.345 634.272 348.345C634.272 348.345 764.971 401.886 860.89 351.936C971.163 294.699 964.953 202.402 957.362 204.197Z"
fill="url(#paint0_linear_4_16)"
stroke="#29293A"
stroke-opacity="0.23"
/>
<defs>
<linearGradient
id="paint0_linear_4_16"
x1="678.412"
y1="-1151.29"
x2="796.511"
y2="832.071"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.572115" stop-color="#B8F1ED" />
<stop offset="0.9999" stop-color="#B8F1CC" />
</linearGradient>
</defs>
</svg>
</div>
<div id="font-size-reduce" class="item" title="缩小字体">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M10.5 7h-2L3 21h2.2l1.1-3h6.2l1.1 3H16zm-3.4 9l2.4-6.3l2.4 6.3zM22 7h-8V5h8z"
/>
</svg>
</div>
<div id="font-size-add" class="item" title="放大字体">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8.5 7h2L16 21h-2.4l-1.1-3H6.3l-1.1 3H3zm-1.4 9h4.8L9.5 9.7zM22 5v2h-3v3h-2V7h-3V5h3V2h2v3z"
/>
</svg>
</div>
<div id="play-prev" class="item" title="上一首">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M7 6c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1s-1-.45-1-1V7c0-.55.45-1 1-1m3.66 6.82l5.77 4.07c.66.47 1.58-.01 1.58-.82V7.93c0-.81-.91-1.28-1.58-.82l-5.77 4.07a1 1 0 0 0 0 1.64"
/>
</svg>
</div>
<!-- 播放暂停 -->
<div id="pause" class="item hidden" title="暂停">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8 19c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2v10c0 1.1.9 2 2 2m6-12v10c0 1.1.9 2 2 2s2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2"
/>
</svg>
</div>
<div id="play" class="item" title="播放">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18a1 1 0 0 0 0-1.69L9.54 5.98A.998.998 0 0 0 8 6.82"
/>
</svg>
</div>
<div id="play-next" class="item" title="下一首">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m7.58 16.89l5.77-4.07c.56-.4.56-1.24 0-1.63L7.58 7.11C6.91 6.65 6 7.12 6 7.93v8.14c0 .81.91 1.28 1.58.82M16 7v10c0 .55.45 1 1 1s1-.45 1-1V7c0-.55-.45-1-1-1s-1 .45-1 1"
/>
</svg>
</div>
<!-- 锁定 -->
<div id="lock-lyric" class="item" title="锁定/解锁">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2M9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9zm9 14H6V10h12zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2"
/>
</svg>
</div>
<!-- 关闭 -->
<div id="close-lyric" class="item" title="关闭">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z"
/>
</svg>
</div>
</div>
</header>
<main id="lyric-content">
<span id="lyric-text">该歌曲暂无歌词</span>
<span id="lyric-tran"></span>
</main>
<script>
class LyricsWindow {
constructor() {
// 获取元素
this.songNameDom = document.getElementById('song-name')
this.songArtistDom = document.getElementById('song-artist')
this.lyricContentDom = document.getElementById('lyric-content')
this.lyricTextDom = document.getElementById('lyric-text')
this.lyricTranDom = document.getElementById('lyric-tran')
this.pauseDom = document.getElementById('pause')
this.playDom = document.getElementById('play')
// 窗口位置
this.isDragging = false
this.startX = 0
this.startY = 0
this.startWinX = 0
this.startWinY = 0
this.winWidth = 0
this.winHeight = 0
// 临时变量
// this.lyricIndex = -1;
// 初始化
this.restoreOptions()
this.menuClick()
this.setupIPCListeners()
this.setupWindowDragListeners()
this.setupMutationObserver()
}
// 歌词切换动画
updateLyrics(content = '纯音乐,请欣赏', translation = '') {
// document.startViewTransition(() => {
// this.lyricTextDom.innerHTML = content;
// this.lyricTranDom.innerHTML = translation;
// });
this.lyricTextDom.innerHTML = content
this.lyricTranDom.innerHTML = translation
}
// 获取配置
async restoreOptions() {
try {
const defaultOptions = await window.electron.ipcRenderer.invoke(
'get-desktop-lyric-option'
)
if (defaultOptions) this.changeOptions(defaultOptions)
return defaultOptions
} catch (error) {
console.error('Failed to restore options:', error)
}
}
// 修改配置
changeOptions(options, callback = true) {
if (!options) return
const { fontSize, mainColor, shadowColor } = options
document.documentElement.style.setProperty('--font-size', fontSize)
document.documentElement.style.setProperty('--main-color', mainColor)
document.documentElement.style.setProperty('--shadow-color', shadowColor)
if (callback) window.electron.ipcRenderer.send('set-desktop-lyric-option', options)
}
// 菜单点击事件
menuClick() {
const toolsDom = document.getElementById('tools')
if (!toolsDom) return
// 菜单项点击
toolsDom.addEventListener('click', async (event) => {
const target = event.target.closest('div')
if (!target) return
console.log(target)
const id = target.id
if (!id) return
// 获取配置
const options = await this.restoreOptions()
switch (id) {
case 'show-app': {
window.electron.ipcRenderer.send('win-show')
break
}
case 'font-size-add': {
let fontSize = options.fontSize
if (fontSize < 60) {
fontSize++
this.changeOptions({ ...options, fontSize })
}
break
}
case 'font-size-reduce': {
let fontSize = options.fontSize
if (fontSize > 10) {
fontSize--
this.changeOptions({ ...options, fontSize })
}
break
}
case 'play': {
window.electron.ipcRenderer.send('send-main-event', 'play')
break
}
case 'pause': {
window.electron.ipcRenderer.send('send-main-event', 'pause')
break
}
case 'play-prev': {
window.electron.ipcRenderer.send('send-main-event', 'playPrev')
break
}
case 'play-next': {
window.electron.ipcRenderer.send('send-main-event', 'playNext')
break
}
case 'close-lyric': {
window.electron.ipcRenderer.send('closeDesktopLyric')
break
}
case 'lock-lyric': {
const locked = !document.body.classList.contains('lock-lyric')
document.body.classList.toggle('lock-lyric', locked)
window.electron.ipcRenderer.send('toogleDesktopLyricLock', locked)
break
}
default:
break
}
})
}
// 监听 IPC 事件
setupIPCListeners() {
window.electron.ipcRenderer.on('play-song-change', (_, title) => {
if (!title) return
const [songName, songArtist] = title.split(' - ')
this.songNameDom.innerHTML = songName
this.songArtistDom.innerHTML = songArtist
this.updateLyrics(title)
})
window.electron.ipcRenderer.on('play-lyric-change', (_, lyricData) => {
if (!lyricData) return
this.parsedLyricsData(lyricData)
})
window.electron.ipcRenderer.on('play-status-change', (_, status) => {
this.playDom.classList.toggle('hidden', status)
this.pauseDom.classList.toggle('hidden', !status)
})
// 配置变化
window.electron.ipcRenderer.on('desktop-lyric-option-change', (_, options) => {
this.changeOptions(options, false)
})
// 歌词锁定(仅更新样式,不再回传,避免事件循环)
window.electron.ipcRenderer.on('toogleDesktopLyricLock', (_, lock) => {
document.body.classList.toggle('lock-lyric', lock)
})
}
// 解析歌词
parsedLyricsData(lyricData) {
if (!this.lyricContentDom || !this.lyricTextDom) return
const { index, lyric } = lyricData
// 更换文字
if (!lyric || index < 0) {
if (lyric.length === 0) this.updateLyrics()
} else {
const { content, tran } = lyric[index]
this.updateLyrics(content, tran || '')
}
}
// 拖拽窗口
setupWindowDragListeners() {
document.addEventListener('mousedown', this.startDrag.bind(this))
document.addEventListener('mousemove', this.dragWindow.bind(this))
document.addEventListener('mouseup', this.endDrag.bind(this))
}
// 开始拖拽
async startDrag(event) {
this.isDragging = true
const { screenX, screenY } = event
const {
x: winX,
y: winY,
width,
height
} = await window.electron.ipcRenderer.invoke('get-window-bounds')
this.startX = screenX
this.startY = screenY
this.startWinX = winX
this.startWinY = winY
this.winWidth = width
this.winHeight = height
}
// 拖拽
async dragWindow(event) {
if (!this.isDragging) return
const { screenX, screenY } = event
let newWinX = this.startWinX + (screenX - this.startX)
let newWinY = this.startWinY + (screenY - this.startY)
const { width: screenWidth, height: screenHeight } =
await window.electron.ipcRenderer.invoke('get-screen-size')
newWinX = Math.max(0, Math.min(screenWidth - this.winWidth, newWinX))
newWinY = Math.max(0, Math.min(screenHeight - this.winHeight, newWinY))
window.electron.ipcRenderer.send(
'move-window',
newWinX,
newWinY,
this.winWidth,
this.winHeight
)
}
// 结束拖拽
endDrag() {
this.isDragging = false
}
// 更新高度
updateWindowHeight() {
const bodyHeight = document.body.scrollHeight
window.electron.ipcRenderer.send('update-window-height', bodyHeight)
}
// 动态监听高度
setupMutationObserver() {
const observer = new MutationObserver(this.updateWindowHeight.bind(this))
observer.observe(document.body, { childList: true, subtree: true, attributes: true })
this.updateWindowHeight()
}
}
new LyricsWindow()
</script>
</body>
</html>